刚上班那会,手指频频出状况,症状是手指在弯曲时有明显的受限,蜷缩手指时会有剧痛,疼痛点像是关节,但没有观察到红肿。中、无名、小指都出现过,左右手也不固定,症状持续时间一般在半周。
我害怕是腱鞘炎,就去风湿科做了检查,起初血检出尿酸超标,看症状医生也没法下定论:腱鞘炎的疼痛通常是在手腕;关节炎、痛风的疼痛处会有明显红肿;风湿在我这个年龄很不常见。唯一的结论是先把尿酸降下来,饮食清淡、多喝水,不配药(没有发生痛风所以不用吃药)。后面尿酸降下去了,但是症状在某天又发生了,后面去了疼痛科门诊,医生认为是轻微腱鞘炎,平时要少重复同一个动作,多多活动。
就医并没有扫清我的疑虑,可能还有相反的作用,不过每天喝1.5L水的习惯被我延续到现在。。。
之前在v2ex搜索相关讨论时,在一个贴子里接触到了一个名词,RSI,全称Repetitive strain injury,中文翻译为重复性劳损,指重复使用、振动、压迫或长期固定姿势引起肌肉骨骼系统或神经系统的损伤。评论中提到的书籍我买了一本放在公司,图书馆藏退役,相当有年代感。zlib上找到了电子扫描版,存到了电纸书上。
这本书我只看了很前面的一部分。该书的背景是电脑刚普及的那段时间,与电脑相关的职业开始变多,大量的人员从事键盘工作,原本只出现在小部分人群(钢琴家之类的)身上的RSI也因此变得普遍。作者是一名记者,她帮助一名这方面专业的医生写下了这本书,向相关从业者普及这个疾病。里面提到了很多预防方法,比如用什么两个核桃来灵活手指,不过最优的治疗方案还是按下停止键,换一份工作。。。
从这本书冷清的现象上来看,作者在书中所说这项疾病会导致终身残疾的言论可能杞人忧天了。事实上,这项疾病并没有太受公众重视,不过后续有关程序员健康的书也偶有出版。我们这类人,多数只有在出现问题的时间段才会关注,但RSI是一种慢性、隐藏的疾病,需要提前预防,事发前提是已经有了长年累月的积累,最后突然爆发疼痛,因果如此。
小指的疼痛我能直指出元凶:左ctrl。在习惯窗口管理器之后,ctrl前缀的快捷键是不可能缺席的,当时我的tmux的prefix还是ctrl-s
,上课经常带着我的笔记本,用小指和食指按prefix就是家常便饭。这种高强度使用,可能已经让我的小拇指到极限了,我现在不敢太频繁用左手小指敲键盘,一频繁使用就会开始起症状。痛,是来自大脑的保护机制。 在不改变必须要使用键盘的前提下,我能做、也必须得做的事情,就是优化我的键盘使用,识别那些会造成伤害的动作,优化它。列举我识别到的“危险”场景:
优化不是件容易的事。最激进的做法是使用定制键盘,两手间距与肩同宽是最佳状态,分体式方案大多是出于这一角度设计,通常为了让手指活动范围缩小,还会采取少键多层的方案,但不管是自己设计还是学习使用现成方案,都是一件成本都很大。按照以往的行事风格,使用定制化键盘似乎是我会采取的方案。我曾买过一个40键的键盘,使用体验可以说非常糟糕,我需要按住某个小格按键,然后以一种极其蹩脚的姿势去按另一个小格按键,才能实现我在其他键盘上按一个按键就能解决的事情,事实上,这项行为本身就类似上面的两种危险场景。分体式键盘理论上不会有太多这种情况,因为每根手指会移动的范围被设计的十分小,不过我还是决定把我的精力花费在我的61键。
我对61键键盘有一种病态的执念,我囤了很多把61键,很多键盘还都是同一个型号,keychron的键盘我很喜欢,还有提过的annepro2。
之前花了一篇blog记录折腾QMK的tap dance。QMK提供给用户一个改善键盘使用的途径,通过在它现有的运行框架上编写自己定制化的函数来让键盘按自己预期的方式运行。不是所有键盘都能使用上QMK,而QMK激进的GPLv2,也让这片闭源土地环境下生长的国内厂商直接绕道。keychron天然支持QMK,annepro2外国网友已经hack成功了,上图中还有几把是没QMK适配的(其中的QK61更是直接被钉上了耻辱柱),大概需要我能力提升之后才能给他们烧上。
第一个场景的解决方案倒挺简单的,把左ctrl放到原capslock的hold层上就行了。不过原先我设计那个位置是Fn1的,就考虑把Fn1上移到tab键的hold层。
[WIN_BASE] = LAYOUT_ansi_61(
KC_GRAVE, KC_1, ...
LT(_FN1,KC_TAB), KC_Q, ...
MT(MOD_LCTL,KC_ESC), KC_A, ...
KC_LSFT, ...
其实hhkb就是这么做的:
第二个场景就比较消耗脑力了,我尝试了网上的脚踏板方案,
之前我已经把空格的hold让给了alt,如果能把这个位置让给shift就完美解决了,但可惜alt键并不像上面的Fn1那么无关紧要,我tmux的prefix是alt-s
,这个位置是不能移的。于是就特想把空格劈开,这么一个黄金区块,居然只分配了空格键这一个大只佬,暴殄天物。
又经过了一些奇葩的方案设想,比如hold字母出大写、hold符号区为shift等,这些方案都多多少少会影响正常输入。最终,我想到了一个很棒的方案:
敲一下shift,在时间窗口内按下空格键,空格会变成shift。
然而实现起来其实并不轻松。起初想用tap dance来完成这项任务,但TD的可定制程度其实没有那么高。这是TD的工作流程,
tap dance允许用户在进入最后处理前多次按下TD键。当用户按下TD后,会有一个空挡窗口期,这这个窗口内:
进入最终处理需要满足以下条件中的任一个:
TAPPING_TERM
对于一个TD定义,QMK提供了四个可定义的函数:
TD的各种场景:
敲一下shift,在时间窗口内按下空格键,空格会变成shift。
试想一下这个方案怎么用TD实现:
显然,符合上图中的中断场景。
那么问题来了,在这个场景中,TD键为shift,但是期望键码改变的,却是触发TD中断的空格。在TD的几个处理函数里,可以使用接口模拟按键事件:持续按住register_code16(keycode)
,松开unregister_code16(keycode)
,按一下tap_code16(keycode)
等。预期一般的TD实现都会在fin决定reg的按键,在reset时unreg,然而我们无法将状态传递给空格键,除非。。。使用两枚TD。
如图,这两枚TD,通过一个全局变量g_flag
进行通信,只有在fin函数里识别到shift键被按下并且松开时置1,在space对应的TD按下时,tab函数检测状态位,若为1,则将当前按键变为shift,否则按原样运行。
typedef struct {
char idx;
uint16_t first;
uint16_t second;
uint16_t second_name;
char *flag;
} td_pair_t;
void fn_td_pair_tap(tap_dance_state_t *state, void *user_data)
{
td_pair_t *data = user_data;
if (!data->idx) return; /* fisrt skip tap */
/* second */
if (*(data->flag)) {
register_code16(data->first);
} else {
register_code16(data->second);
}
state->finished = 1; /* second skip finished */
}
void fn_td_pair_finished(tap_dance_state_t *state, void *user_data)
{
/* only first will in */
td_pair_t *data = user_data;
if (state->interrupted
&& (state->interrupting_keycode == TD(data->second_name))) {
*data->flag = 1;
} else {
register_code16(data->first);
}
}
void fn_td_pair_reset(tap_dance_state_t *state, void *user_data)
{
td_pair_t *data = user_data;
if (data->idx) { /* second */
if (*data->flag) {
unregister_code16(data->first);
*data->flag = 0;
} else {
unregister_code16(data->second);
}
} else { /* first */
if (!*data->flag) {
unregister_code16(data->first);
}
}
}
到此为止,这个功能算是实现了,但好事多磨,我原本空格绑定的键是MT(MOD_LALT,KC_SPC)
,很不幸的,那些接口无法模拟MT
宏,现在只能当作空格来用。不过可以参考官方文档,使用TD来实现MT,然后强行把这两种TD行为缝合在一起。但我后面并没有如此实现,我自己试了一下教程的写法,把现在的MT用TD实现了一遍,手感非常奇怪:
针对这些异常点分析,会发现TD根本无法完美复刻MT。
想象一下高速的打字场景,在空格选择完上一个候选词后,马上输入下一码,假设此时TD空格没有完全松开,这将会触发TD的按键按键中断,会立即执行fin来决定TD表示的按键,我们会希望fin的结果是tap,否则空格会变成alt,相当于是空格被吞了。
再试想另一个tmux场景,使用hold + h来切换pane,这两个按键的输入很快,在按下TD空格后,TD的等待窗口还没结束就按下了h,这也会触发TD的按键中断,必须立即执行fin,此时我们会希望fin的结果是hold,否则只是输入空格+h,无法完成切换pane的任务。
这是矛盾的,无法实现一个完美的逻辑来决定某个中断场景到底应该是hold还是tap,在我的日常使用中高频存在这两种场景,所以TD模拟的MT会降低效率。
MT的机制并不算复杂,MT只关注自身按下和松开的时间,如果时间超过窗口就是hold,否则是tap。与TD的不同,MT会忽视窗口内来自其他按键的中断。再想一下上面两种场景,如果忽视其他按键的影响,不在发生中断时立即处理就能刚好解决了,这就是MT使用舒适背后的原理。
事实上,MT这项忽略其他按键事件的特性是某次QMK大更新后才变成了MT的默认行为。
docs/ChangeLog/20230226.md:7:
IGNORE_MOD_TAP_INTERRUPT_PER_KEY
has been removed andIGNORE_MOD_TAP_INTERRUPT
deprecated as a stepping stone towards makingIGNORE_MOD_TAP_INTERRUPT
the new default behavior for mod-taps in the future.
综上,结对TD的方案否决。
之后的方案,自然而然地会向更底层的方向上去靠。
QMK是单线程的while(1),每次循环会先扫描按键矩阵,与上次扫描结果异或出状态改变的按键,这些按键事件放进一个处理队列,按序处理,处理流程如下:
pre_process
pre_process_record_kb
pre_process_record_user(uint16_t keycode, keyrecord_t *record)
process_record
process_record_kb
process_record_user
process_tap_dance
---
post_process
post_process_record_kb
post_process_record_user
(from docs/understanding_qmk.md)
理论上,在预处理阶段(pre_process
)时,应该可以对按键的keycode进行替换。对于空格来说,默认行为为MT(LALT, SPACE)
,特殊情况下替换为MT(LSHIFT, SPACE)
。同时也可以在某个地方记录单独按下shift键的时间节点,借此作为触发替换的条件。
默认情况下,QMK不允许直接用户对keycode进行操作,在keyrecord_t
的定义里发现该操作需要开启一个条件宏:
typedef struct {
keyevent_t event;
#ifndef NO_ACTION_TAPPING
tap_t tap;
#endif
#if defined(COMBO_ENABLE) || defined(REPEAT_KEY_ENABLE)
uint16_t keycode;
#endif
} keyrecord_t;
(from quantum/action.h)
开启COMBO_ENABLE
或者REPEAT_KEY_ENABLE
,后者看着怪怪的,选择前者。看了combo的介绍,这是一个很有趣的特性,同时按下某些按键,会返回指定的结果,速录机belike,不过暂且先不用这个功能,但是呢,开启这个宏后,如果不用这个功能编译不过,所以还是稍微应付一下。以下就是按照上述思路增加的代码:
rules.mk:
COMBO_ENABLE = yes
-----------------------------
keymap.c:
// 因为这个功能有点奇怪,就取名为odd了
#define ODD_IDX(KC) ((KC) - ODD_BG - 1)
enum odd_keycodes {
ODD_BG = SAFE_RANGE, // SAFE_RANGE,框架不会占用的keycode
KC_ODD_SPACE, // 主角按键名
ODD_ED
};
// 回调函数接口定义
typedef bool (*odd_fn_t)(uint16_t keycode, keyrecord_t *record);
// 分别表示这个odd键在各阶段调用的回调函数
typedef struct {
odd_fn_t pre;
odd_fn_t process;
odd_fn_t post;
} odd_action_t;
// 结构体登记表
odd_action_t odd_actions[] = {
[ODD_IDX(KC_ODD_SPACE)] = {pre_odd_space, NULL, NULL},
};
// 全局变量
uint16_t g_odd_space_time; // 记录上一次单独按下shift的时间
uint16_t g_odd_space_type; // 记录当前odd空格的形态(键码)
// 唯一的回调函数
bool pre_odd_space(uint16_t keycode, keyrecord_t *record)
{
static uint16_t now_kc = MT(MOD_LALT, KC_SPC);
static uint16_t release_time;
if (record->event.pressed) { // 按下ODD键
if (timer_elapsed(g_odd_space_time) < TAPPING_TERM) { // 如果离单按shift的时间间隔小于窗口
switch (g_odd_space_type) {
case KC_LSFT: // 并且当前空格未被改变,转变hold状态为shift
now_kc = MT(MOD_LSFT, KC_SPC);
break;
}
} else if (timer_elapsed(release_time) >= TAPPING_TERM) { // 离上次松开按键,已经大于用于连按的窗口,就重置
now_kc = MT(MOD_LALT, KC_SPC);
}
} else { // 松开ODD键,记录松开的时间点
release_time = record->event.time;
}
record->keycode = now_kc; // 赋值生效
return true;
}
const uint16_t PROGMEM mkcbhp[] = {COMBO_END}; // mkcbhp -> make combo happy :)
combo_t key_combos[] = {
COMBO(mkcbhp, 0),
};
// 通用的odd预处理回调轮循
bool pre_process_record_user(uint16_t keycode, keyrecord_t *record)
{
switch (keycode) {
case ODD_BG ... ODD_ED:
if (odd_actions[ODD_IDX(keycode)].pre) {
return odd_actions[ODD_IDX(keycode)].pre(keycode, record);
}
break;
}
return true;
}
// 通用的odd处理回调轮循
bool process_record_user(uint16_t keycode, keyrecord_t *record)
{
static char shift_sign = 0;
switch (keycode) {
case ODD_BG ... ODD_ED:
if (odd_actions[ODD_IDX(keycode)].process) {
return odd_actions[ODD_IDX(keycode)].process(keycode, record);
}
break;
case KC_LSFT: // 已经关于shift的信息
if (record->event.pressed) { // 按下
/* start count */
shift_sign = 1;
} else if (shift_sign) { /* release */ // 松开再记录
/* count result: only press&release shift */
g_odd_space_time = record->event.time;
g_odd_space_type = keycode;
shift_sign = 0;
}
break;
default:
shift_sign = 0; //其他按键时,清空记录,只能单独按下才算
}
return true;
}
// 通用的odd后处理回调轮循
void post_process_record_user(uint16_t keycode, keyrecord_t *record)
{
switch (keycode) {
case ODD_BG ... ODD_ED:
if (odd_actions[ODD_IDX(keycode)].post) {
odd_actions[ODD_IDX(keycode)].post(keycode, record);
}
break;
}
}
这个方案不错,按照预期运行,也不影响我的原有习惯。但是还有一个小问题:打开中文输入法的时候,单独按下shift键,会改变中英种类,此时按住空格模拟按住shift,虽然打出的字母都是大写的,但是打符号会变成非预期的中文/英文版本。这还是有一点烦人的,每次要先关闭输入法,不过解决方案应该也简单,比如说,shift改成TD,在窗口内按下空格,是中断事件,在空格作为中断按键的场景下不单按shift即可。