QMK Firmware で Raise/Lower と変換/無変換を同じキーに割り当てる

自作キーボード向けのオープンソースファームウェアの QMK Firmware は、LT(layer, kc) という特殊なキーコードを用意している。これを使うと、通常のキーコード(A とか)とレイヤー切り替えキーを同じキーに同時に割り当てることができるので、例えば、レイヤー切替の LOWER キーと「無変換」を一つのキーに収容するといったことができる。

ところが、この LT キーを押してから離す操作を非常に素早く行うと、レイヤーの切り替えがうまく行われずに誤入力が発生する問題がある。これはキーマップ (keymaps) の設定ではどうにもならないが、代わりに keymap.c の process_record_user 関数に手を加えることで解決できる。

この方法はあぷろさんに教えて頂いたのだが、知見を共有する意味で記事として残しておくことにしたい。具体的な実装例は本文の最後に掲載する。

なぜ親指のキー配置が重要か

僕は WindowsLinux を使う際に、日本語入力の IME オフを「無変換」キーに、IME オンを「変換」キーに割り当てるようにしている。この二つのキーの配置は Mac の JIS キーボードにおける「かな」キーや「英数」キーと同じであり、ホームポジションに置いた親指で操作できるのでアクセスしやすい。また、「半角/全角」キーと違って、現在の入力モードが英語なのか日本語なのかを気にせずに所望のモードへ確実に切り替えることができる。

Windows では歴史的経緯により別の機能が割り当てられてきたが、このようなメリットが考慮されたのか、Windows 10 の次期バージョンではこのキーアサインを既定にすることも検討されている。

blogs.windows.com

僕にとっては、この「確実に切り替えられる」という点が重要で、プログラムを書いたりコマンドを打ち始める前に、とりあえず無変換キーを連打するクセがついてしまっているほどだ。そのようなわけで、自作キーボードにおいても「無変換」と「変換」をなるべく親指でアクセスしやすい位置に置いておきたいという要求がある。

一方で、Planck や Let's Split に代表される 40% キーボードでは RaiseLower の二つのレイヤー切り替えキーが重要な役割を果たす。この種のキーボードではホームポジションから遠いキーをバッサリと削った結果、数字や記号は Raise/Lower との組み合わせで入力するようになっているので、これらもなるべく親指付近に置いておきたい。

その他にも親指で操作したいキーにはスペースやエンターなどもあるわけで、親指でアクセスしやすい一等地である↓の辺りはいよいよ混み合ってくる。

f:id:okapies:20190202133823j:plain
ErgoDash の親指クラスタ

ところが、親指という指は一般に考えられているほど器用ではないし、可動域もさほどではない。個人的には、親指で使いこなせるキーはせいぜい三つで、理想的には二つに抑えたいという感じがする。だから、使いたいキーが三つあるなら、うち二つのキーを一つの物理キーに収容して使い分けたくなる。

そこで、「変換/無変換」と「Raise/Lower」はそれぞれ単発押し (tap) と長押し (hold) なので、キーの押され方をファームウェアで識別すれば二つのキーを一つの物理キーで扱えるんじゃないか、というアイディアにたどり着く。

LT キーの問題点

QMK Firmware には、LT(layer, kc) という特殊なキーコードが用意されている。これを設定した物理キーを単発押しすると kc で指定したキーコードを送るが、長押しすると代わりに layer に切り替わる。これを使うと単発押しと長押しを一つの物理キーに収められるので、以下のように設定してやれば問題は解決…

#define KC_LOMH LT(_LOWER, KC_MHEN)
#define KC_RAHE LT(_RAISE, KC_HENK)

…しない。

これ、動くには動くのだが、おそらく多くの人間が期待していない動作をする。簡単に説明すると、QMK は長押しを「キーが 200 ミリ秒押され続けているか否か」で判定しているのだが、例えば「RAISE 押す → E 押す → RAISE 離す」を非常に素早く入力すると長押しの判定が成立せず、# を入力したいのに E になってしまう上に IME オンも働いてしまい、とても残念なことになる。

これを防ぐには RAISE を押して一呼吸おいてから文字の入力を始める、というような人間側のワークアラウンドが必要で、とてもストレスフルだ。

長押し判定の秒数は TAPPING_TERM という定数で決まっているので、これを小さく設定すれば問題は軽減するが根本的な解決ではない。その上、副作用として、通常の単発押しの受付時間も短くなってしまうので、変換キーを押して 100 ms 以内に離さないと入力が失敗するといったことが起きる。これはこれでつらい。

この件について、一年ほど前に Reddit で相談した際に QMK コントリビュータの人からも話を聞けたのだが、結論としては「あまりいい解決策はない」ということになってしまった。私の理解では、これは QMK の設計の根幹に関わる問題*1なので、残念ながら当分は改善されないと思われる。

www.reddit.com

解決編

この問題が未解決のまま一年ほど我慢して使っていたのだが、今年に入って ErgoDash mini を作ったのを機に「本格的にどうにかせねばなぁ」という機運が高まってきた。そこで自作キーボード Discordファームウェアに詳しい人たちに相談したところ、あっさりと解決してしまった。最初から聞いておけば…。

上記の通り、QMK の機能では対応していないので keymaps 配列の設定を工夫しても解決しない。そこで、同じ keymap.c にある process_record_user というキーの上げ下げが通知されるイベントハンドラを自分で実装すれば、Raise/Lower の長押しに関する挙動をカスタマイズできる(よく読み返すと、上記の Reddit スレでも軽く言及されている)。

github.com

おそらく、多くのキーボードの keymap.c には以下のようなデフォルト実装が書かれていると思う。

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  return true;
}

あるいは、最初から以下のようなコードが書かれているかもしれない(update_tri_layer という関数の説明はこの辺にある。ここでは関係ないので無視してよい)。

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  switch (keycode) {
    case LOWER:
      if (record->event.pressed) {
        layer_on(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      } else {
        layer_off(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      }
      return false;
      break;
      ...
  }
  return true;
}

これを書き換えて、LT に頼らずに LOWER キーを押して離した際の挙動をカスタマイズする。例えば:

static bool lower_pressed = false; // (1)

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  switch (keycode) {
    case LOWER:
      if (record->event.pressed) {
        lower_pressed = true; // (2)

        layer_on(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      } else {
        layer_off(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);

        if (lower_pressed) { // (4)
          register_code(KC_MHEN); // macOS の場合は KC_LANG2
          unregister_code(KC_MHEN);
        }
        lower_pressed = false;
      }
      return false; // (5)
      break;
    ...
    default: // (3)
      if (record->event.pressed) {
        // reset the flag
        lower_pressed = false;
      }
      break;
  }
  return true;
}

ポイントとしては以下のような感じ。

  • (1): LOWER キーの上げ下げを記録する lower_pressed 変数(値を保管する箱)を用意する
  • (2): LOWER キーが押された(record->event.pressedtrue)ら、それを lower_pressed に記録する
  • (3): 他のキーが押されたら lower_pressed をリセットする(return はしない)
  • (4): LOWER キーを離した時に lower_pressed がリセットされていない時だけ「無変換」キーを入力する
    • LOWER -> E」と組み合わせて押した場合は lower_pressed がリセットされた状態なので無変換キーは入力されない
  • (5): return false; と書く(LOWER キーについて、以降の QMK 本体側での処理を行わないことを示す)

もう少し複雑な制御(LOWER を長押ししたら、他のキーと組み合わせない場合も「無変換」を入力しない)を実装したバージョンを gist に置いておくので、もしよければご参考までに。

まとめ

このように、QMK は細かいカスタマイズをしようとすると途端に難しくなる場合が多い(これを非プログラマの人にやってもらうのはなかなか厳しい)。この辺りが、「新しい自作キーボードファームウェアを作りたい」という話が出てくる一つの動機になっている。自作キーボード Discord の #arm-keyboard-firmware チャンネルでは、この辺りの話題について議論が交わされているので、興味のある人は覗いてみてほしい。

補足

他の方法について指摘を頂いたので紹介させて頂きたい。特に MACRO_TAP_HOLD_LAYER は有用そうなのでいずれ試してみたい。

*1:TMK/QMK はキーマトリクスの走査結果をバッファせず、直接イベントハンドラをコールバックするアーキテクチャになっており、前後のキー入力イベントに依存して出力を変えるのが難しい。