Karabiner-Elements を使っている Mac ユーザーは多いと思いますが、設定ファイルが JSON で 2000 行を超えてくると「もう管理できない」状態になりませんか?
僕は Karabiner-Elements をアプリ名が「Karabiner」だった頃から使っています。最初は GUI で設定を書いていたんですが、カスタマイズが複雑になるにつれて JSON ファイルが肥大化。ある時「TypeScript で設定を書ける」と知って、すぐに飛びつきました。
この記事では、karabiner.tsを使って Karabiner-Elements の設定を TypeScript で管理する方法を紹介します。実際に僕が使っている設定も公開するので、参考にしてみてください。
なぜTypeScriptで設定を書くのか
海外の開発者の間では「2700 行の JSON が 300 行のコードになる」という報告もあります。僕自身も、JSON で書いていた頃は設定の全体像を把握するのが難しかったんですが、TypeScript に移行してからは圧倒的に管理しやすくなりました。
TypeScript で設定を書くメリットはこんな感じです。
karabiner.tsのインストール
karabiner.tsは、Karabiner-Elements 設定を TypeScript で記述するためのライブラリです。作者の evan-liu さんが開発していて、GitHub で 310 スター以上を獲得しています。
Node.jsの場合
npx create-karabiner-config@latest
対話形式でプロジェクトが作成されます。
Denoの場合
Deno ならnode_modulesの管理が不要なので、さらにシンプルです。
import { writeToProfile, rule, map } from 'https://deno.land/x/karabinerts/mod.ts'
オンラインエディタ
ブラウザで試したい場合は、オンラインエディタも用意されています。
基本的な使い方
まずはシンプルな例から。src/index.tsを作成して、以下のように書きます。
import {
map,
rule,
writeToProfile,
} from 'karabiner.ts'
writeToProfile('Default', [
rule('CapsLock to Control').manipulators([
map('caps_lock').to('left_control')
])
])
npx ts-node src/index.tsを実行すると、~/.config/karabiner/karabiner.jsonが自動生成されます。
僕の設定を公開する
ここからは実際に僕が使っている設定を紹介します。正直かなり癖のある設定ですが、手の移動を最小限にするために最適化した結果こうなりました。
全体構成
import {
ifApp,
ifDevice,
ifInputSource,
map,
mapDoubleTap,
mapSimultaneous,
rule,
toKey,
toSetVar,
withCondition,
writeToProfile,
} from 'karabiner.ts'
import { toSymbol } from "./utils"
function main() {
writeToProfile('Default', [
ruleBasic(),
ruleApp(),
ruleOptionSymbol(),
ruleBuildInKeyboard(),
ruleNotBuildInKeyboard(),
ruleIme(),
], {
'basic.simultaneous_threshold_milliseconds': 150,
})
}
main()
設定をルール単位で関数に分割しています。こうすることで、どの設定がどこにあるかが一目でわかります。
基本設定:Ctrl + Hでバックスペース
const ruleBasic = () => {
return rule('Basic').manipulators([
// Ctrl + C でEscapeとIME OFF(ターミナル以外)
withCondition(ifApp(['^com.googlecode.iterm2$', 'com.mitchellh.ghostty']).unless())([
map('c', '⌃').to('escape').to('japanese_eisuu')
]),
// Ctrl + H でバックスペース
map('h', '⌃').to('⌫'),
// Cmd + hjkl で矢印キー
map('h', '⌘').to('←'),
map('j', '⌘').to('↓'),
map('k', '⌘').to('↑'),
map('l', '⌘').to('→'),
// Cmd + i/o で行頭/行末
map('i', '⌘').to('a', '⌃'),
map('o', '⌘').to('e', '⌃'),
// Cmd + , . でタブ切り替え(JetBrains以外)
withCondition(ifApp(['^com.jetbrains.[\\w-]+$']).unless())([
map('.', '⌘').to('tab', ['⌃']),
map(',', '⌘').to('tab', ['⌃', '⇧']),
]),
])
}
Ctrl + Hでバックスペースは、シェルでもエディタでも使える定番設定ですね。Cmd + hjklで Vim ライクなカーソル移動ができるのも便利です。
ちなみにコード内で言及している Ghostty については、以下の記事で設定方法を解説しています。
Ghostty入門|HashiCorp創業者が作った次世代ターミナルの設定ガイドMacアプリ別設定:JetBrainsやFinderで特別な動作
const ruleApp = () => {
return rule('app').manipulators([
// JetBrains IDEでのタブ切り替え
withCondition(ifApp(['^com.jetbrains.[\\w-]+$']))([
map('.', '⌘').to(']', ['⌘', '⇧']),
map(',', '⌘').to('[', ['⌘', '⇧']),
// 保存時にプロジェクトウィンドウにフォーカス
map('s', '⌘').to('s', '⌘').to('[', '⌃'),
]),
// Finderでの操作
withCondition(ifApp(['^com\\.apple\\.finder$', '^com\\.cocoatech\\.PathFinder$']))([
map('j', '⌘').to('close_bracket', ['⌘', '⇧']),
map('k', '⌘').to('open_bracket', ['⌘', '⇧']),
map('n', '⌃').to('down_arrow'),
map('p', '⌃').to('up_arrow'),
]),
])
}
ifAppを使うと、特定のアプリケーションでのみ有効な設定が書けます。正規表現でバンドル ID を指定するので、JetBrains 製品すべてに一括で適用できます。
Optionキーでシンボル入力
これは US キーボードユーザーにはかなりおすすめの設定です。
const ruleOptionSymbol = () => {
return rule("optionSymbol").manipulators([
// 上段:記号
map('q', '⌥').to(toSymbol['!']),
map('w', '⌥').to(toSymbol['@']),
map('e', '⌥').to(toSymbol['#']),
map('r', '⌥').to(toSymbol['$']),
map('t', '⌥').to(toSymbol['%']),
map('y', '⌥').to(toSymbol['^']),
map('u', '⌥').to(toSymbol['&']),
map('i', '⌥').to(toSymbol['*']),
map('o', '⌥').to(toSymbol['`']),
map('p', '⌥').to(toSymbol['~']),
// 中段:括弧と演算子
map('a', '⌥').to(toSymbol['+']),
mapDoubleTap('s', '⌥').to(toKey('0', ['left_shift'])).singleTap(toKey('9', ['left_shift'])),
mapDoubleTap('d', '⌥').to(toSymbol[']']).singleTap(toKey('open_bracket')),
map('f', '⌥').to(toSymbol['-']),
map('g', '⌥').to(toSymbol['=']),
// Option + hjkl で矢印キー
map('h', '⌥').to('←'),
map('j', '⌥').to('↓'),
map('k', '⌥').to('↑'),
map('l', '⌥').to('→'),
// 下段:クォートやパイプ
map('z', '⌥').to(toSymbol['"']),
map('v', '⌥').to(toSymbol['\'']),
map('b', '⌥').to(toSymbol['"']),
map('n', '⌥').to(toSymbol['|']),
map('m', '⌥').to(toSymbol['_']),
map('/', '⌥').to(toSymbol['\\']),
])
}
mapDoubleTapを使うと、シングルタップとダブルタップで異なる文字を入力できます。例えばOption + Sをシングルタップで(、ダブルタップで)を入力する設定にしています。
補助用のユーティリティ関数も用意しています。
// utils.ts
import { toKey } from "karabiner.ts"
export const toSymbol = {
'!': toKey(1, '⇧'),
'@': toKey(2, '⇧'),
'#': toKey(3, '⇧'),
'$': toKey(4, '⇧'),
'%': toKey(5, '⇧'),
'^': toKey(6, '⇧'),
'&': toKey(7, '⇧'),
'*': toKey(8, '⇧'),
'(': toKey(9, '⇧'),
')': toKey(0, '⇧'),
'[': toKey('['),
']': toKey(']'),
'{': toKey('[', '⇧'),
'}': toKey(']', '⇧'),
'-': toKey('-'),
'=': toKey('='),
'_': toKey('-', '⇧'),
'+': toKey('=', '⇧'),
';': toKey(';'),
':': toKey(';', '⇧'),
'\\': toKey('\\'),
'|': toKey('\\', '⇧'),
'\'': toKey('quote'),
'"': toKey('quote', '⇧'),
'`': toKey('`'),
'~': toKey('`', '⇧'),
}
内蔵キーボードと外付けキーボードで設定を分ける
MacBook の内蔵キーボードと外付けキーボード(QMK キーボードなど)で異なる設定を適用できます。
const ruleBuildInKeyboard = () => {
return rule("buildIn")
.condition(ifDevice({ is_built_in_keyboard: true }))
.manipulators([
// EnterをControlに、単独押しでEnter
map('return_or_enter').to('left_control').toIfAlone('return_or_enter'),
// Cmd単独押しでSpace
map('left_command').to('left_command').toIfAlone('spacebar'),
// Option単独押しでTab
map('left_option').to('left_option').toIfAlone('tab'),
// 右Cmd単独押しでバックスペース
map('right_command').to('right_option').toIfAlone('delete_or_backspace'),
// カンマのダブルタップでコロン
mapDoubleTap(',').to(toSymbol[':']),
])
}
const ruleNotBuildInKeyboard = () => {
return rule("notBuildIn")
.condition(ifDevice({ is_built_in_keyboard: false }))
.manipulators([
// 右Shiftを押している間だけ日本語入力
map('right_shift')
.to([
toSetVar('caps_lock_pressed', 1),
toKey('japanese_kana'),
])
.toAfterKeyUp([
toSetVar('caps_lock_pressed', 0),
toKey('japanese_eisuu'),
]),
])
}
ifDevice({ is_built_in_keyboard: true })で内蔵キーボードを判定しています。外付けキーボードでは QMK でカスタマイズしているので、Karabiner 側では最低限の設定だけにしています。
QMK キーボードでの記号入力については、以下の記事で詳しく解説しています。
40%キーボードで記号を効率よく打つ工夫ワーク環境IME切り替え:jkの同時押しで英数
これが僕の設定で一番癖のある部分です。
const ruleIme = () => {
return rule('Ime').manipulators([
// Escapeで英数に切り替え
map('escape').to('escape').to('japanese_eisuu'),
// 日本語入力時の設定
withCondition(ifInputSource({ language: 'ja' }))([
// スラッシュとハイフンを入れ替え
map('slash').to('hyphen'),
map('hyphen').to('slash'),
]),
// D + F の同時押しで日本語入力ON
mapSimultaneous(['d', 'f']).to('japanese_kana'),
// J + K の同時押しで英数(日本語入力中)
withCondition(ifInputSource({ language: 'ja' }))([
mapSimultaneous(['j', 'k']).to('japanese_eisuu'),
]),
// J + K の同時押しでEscape(英数入力中)
withCondition(ifInputSource({ language: 'ja' }).unless())([
mapSimultaneous(['j', 'k']).to('escape')
]),
])
}
mapSimultaneousを使うと、2 つのキーの同時押しをトリガーにできます。d + fで日本語入力 ON、j + kで英数(または Escape)という設定にしています。
ホームポジションから手を動かさずに IME 切り替えができるので、一度慣れると元に戻れなくなります。
Vimエミュレーション(おまけ)
以前は使っていた Vim エミュレーションの設定も紹介します。今は使っていませんが、参考になるかもしれません。
import { duoLayer, ifVar } from 'karabiner.ts'
const vimLayerKey = 'vimLayerKey'
const vimVisualMode = 'VimLayerVisualMode'
const ruleVimForJapanese = () => {
return duoLayer('j', 'k', vimLayerKey)
.condition(ifApp(ignoreVimEmulation).unless())
.toIfActivated(toKey("japanese_eisuu"))
.toIfActivated(toSetVar(vimVisualMode, 0))
.threshold(100)
.leaderMode({ sticky: true })
.notification('Normal Mode (Vim Emulation)')
.manipulators([
// インサートモードへ
map('a').to('→').toVar(vimLayerKey, 0),
map('i').toVar(vimLayerKey, 0),
// 新しい行を作成
map('o').to('e', 'left_control').to('return_or_enter').toVar(vimLayerKey, 0),
// Undo
map('u').to('z', '⌘'),
// 削除
map('x').toVar(vimVisualMode, 0).to('⌦'),
// ペースト
map('p').to('v', '⌘').toVar(vimVisualMode, 0),
// ノーマルモードでの移動
withCondition(ifVar(vimVisualMode, 0))([
map('h').to('←'),
map('j').to('↓'),
map('k').to('↑'),
map('l').to('→'),
map('w').to('right_arrow', ['⌥']),
map('b').to('left_arrow', ['⌥']),
// vでビジュアルモードへ
mapDoubleTap('v', 150)
.toVar(vimVisualMode, 1)
.to('a', '⌃')
.to('e', '⌃⇧')
.singleTap(toSetVar(vimVisualMode, 1)),
// yyで行コピー
mapDoubleTap('y').to('a', '⌃').to('e', '⌃⇧').to('c', '⌘'),
// ddで行削除
mapDoubleTap('d')
.to('a', '⌃')
.to('e', '⌃⇧')
.to('c', 'left_command')
.to('delete_or_backspace')
.toVar(vimVisualMode, 0),
]),
// ビジュアルモードでの選択
withCondition(ifVar(vimVisualMode, 1))([
map('h').to('←', '⇧'),
map('j').to('↓', '⇧'),
map('k').to('↑', '⇧'),
map('l').to('→', '⇧'),
map('d').to('c', 'left_command').to('delete_or_backspace').toVar(vimVisualMode, 0),
map('y').to('c', 'left_command').toVar(vimVisualMode, 0),
]),
])
}
duoLayerは、2 つのキーを同時押しすると「レイヤー」に入る機能です。j + kを押すと Vim のノーマルモードに入り、h/j/k/lで移動、vでビジュアルモード、dで削除…といった操作ができます。
leaderMode({ sticky: true })を指定すると、一度レイヤーに入ったら明示的に抜けるまでそのままになります。
ただ、JetBrains IDE や iTerm2 など Vim プラグインがあるアプリでは使いたくないので、ifApp(ignoreVimEmulation).unless()で除外しています。
設定の自動適用
ファイルを保存するたびに手動で変換するのは面倒なので、ファイル監視で自動適用するのがおすすめです。
Denoの場合
// deno.json
{
"tasks": {
"watch": "deno run --watch --allow-all src/index.ts"
}
}
deno task watch
Node.jsの場合
ts-node-devやnodemonを使う方法もありますが、海外の開発者の間では LaunchAgent でシステム起動時に自動実行させる方法も紹介されています。
まとめ
Karabiner-Elements を TypeScript で設定する方法を紹介しました。
JSON で設定を書いていた頃は「どこを変更したらいいかわからない」状態でしたが、TypeScript に移行してからは設定の見通しがよくなり、新しいカスタマイズを追加するのも簡単になりました。
僕の設定はかなり癖が強いですが、コードを参考にして自分好みにカスタマイズしてみてください。karabiner.ts の公式ドキュメントにはもっと多くの機能が紹介されているので、興味があればチェックしてみてください。
MacBook のキーボード体験をさらに向上させたい方は、打鍵音をカスタマイズできる Klack もおすすめです。
MacBookの打鍵音をメカニカルキーボード風に変えるKlackを3ヶ月使った感想|遅延やバッテリー消費も検証Mac