第1章:なぜ親指シフトを、自分の手で作ったのか

全体像の中でこの章が扱う部分: 主経路(詳細は付録「全体像」を参照)

親指シフトでは、文字キーと親指キーを、ピアノの和音のように合わせて押します。片方の手で文字キーを押しながら、もう片方の手で親指キーをそっと添えると、単独で押したときとは違う文字が生まれます。決めるのはキーの位置ではありません。二つのキーが「間に合っているかどうか」という、指の間の短い時間差です。

この時間差を正しく扱えるかどうかで、指を動かした結果が、意図した文字になるか、まるで違う文字になるかが決まります。既製のキーボードソフトを使っていたときに苦しめられたのは、まさにこの一点でした。狙った通りに合わせて押しているつもりでも、ソフトの側が正しく読み取ってくれない場面が、繰り返しありました。

この時間差を扱うために、2026年3月28日、私は自分の手でキーボードエミュレータを書き始めました。その日のうちに、変換の最小経路はすでに動いていました。ただし、動いたことは、正しく動くことをまだ何も意味していませんでした。

二つのキーが「間に合う」かどうかで、文字が変わる

一般的なキーマッピングでは、キーと文字は一対一で対応します。Aキーを押せばAが出る、という表さえあれば十分です。ShiftキーやCtrlキーとの組み合わせも、押している間だけ状態が変わるという意味では、やはり静的な表の延長にあります。

しかしNICOLA方式の親指シフトでは、文字キーと親指キーのあいだの時間差そのものが変換結果を左右します。近いタイミングで押せば同時打鍵、間隔が開けば単独打鍵として扱われます。押しているかどうかという二値の状態だけでは、この判定を表現できません。

ここでいうキーボードフックとは、OSが発生させるキー入力を、本来の送り先に届く前に横取りし、内容を確認・加工できる仕組みを指します。フックが受け取れるのは、個々のキーの押下・離上イベントとそのタイムスタンプだけであり、そこから「二つのイベントは同時打鍵と呼べる関係にあるか」を組み立てる必要があります。この判定を具体的にどう組んだかは後の章で扱いますが、ここではまず、親指シフトが「配列表」の話ではなく「タイミング」の話だという点だけを押さえておきます。

単純なキーリマッパーの多くは、キーを別のキーへ静的に置き換える前提で作られています。その前提に同時打鍵の判定を後から足すのは、機能追加というより設計の作り直しに近い作業でした。ゼロから実装を選んだ理由をこの一点だけに帰する記録は残っていませんが、現在振り返ると、この構造的な違いは無視できない理由の一つだったと思います。

やまぶきRは、なぜ定期的に止まったのか

Windows向けの親指シフトエミュレータは、当時すでにいくつも存在していました。設計文書の冒頭には、次のような目的が書かれています。

Windows 上で動作するキーボード配列エミュレータを Rust で開発する。既存の「やまぶき」「DvorakJ」と同等の機能を持ち、NICOLA(親指シフト)を含む任意のキー配列をエミュレートできる常駐型ツールを目指す。

参考にしたプロジェクトも同じ文書に列挙されています。kanataからはLLHOOKバックエンドの実装パターンとスキャンコードベースの入出力を、やまぶきからはNICOLA配列の挙動仕様を、DvorakJからは機能範囲を、それぞれ参考にしたとされています。特に、kanataがplatform-known-issues.adocにまとめていたWindowsのLLHOOK固有の既知問題は、後述するガード設計に直接反映されたとあります。

自作を選んだ理由は、記録より先に、私自身の使用経験にありました。もともと使っていたのはやまぶきRでした。ところが定期的にプロセスが止まり、そのたびに手動で再起動する必要がありました。Zoomなど一部のアプリとは相性が悪く、うまく動かない場面もありました(この一文はADRのような一次資料ではなく、私自身の記憶に基づく記述です)。そしてWindows向けの親指シフトソフトで、開発が継続されているものが、そもそも見当たりませんでした。使い続けるための選択肢は、実質的に自分で直す以外にありませんでした。

開発が継続されているものが見当たらないという状況は、不具合が見つかっても直してもらえる見込みがないことを意味していました。自分で直す以外に選択肢がないなら、ソースコードごと手元に置いておく以外に、長く使い続ける方法はありませんでした。

なぜ止まりやすかったのか、当時は理由が分かっていませんでした。やまぶきはソースコードが公開されていないクローズドソースのソフトウェアです。本書の執筆にあたり、配布されている実行ファイルが呼び出す外部関数の一覧(インポートテーブル)と、内部の文字列を解析したところ、理由の一端が見えてきました。旧版のやまぶき4は、キーボードフックのコールバックの中でIME操作のAPI(ImmSetConversionStatus)を直接呼び出し、同時打鍵かどうかの判定にはSleepを使って、フックのスレッドそのものを一時停止させていました。やまぶきRでは、IME状態を直接書き換える呼び出しは姿を消していましたが、タイミング判定のためのSleepだけは、フックの中に残ったままでした。

フックコールバックは、短時間で処理を返さなければ、Windowsによって強制的に解除されます。フックは玄関先で来客の用件を一瞬だけ確認する係のようなもので、そこで長く引き止めれば、OSはその係ごと持ち場から外してしまいます。Sleepでスレッドを止める処理は、この「短時間で返す」という前提に反していました。この解析はバイナリの構造から読み取った推定であり、断定はできません。それでも、旧版から新版にかけて問題の一部にだけ手が入り、フックの中で時間のかかる処理をするという前提そのものには触れられていなかった、という輪郭は見えてきます。問題の一部にだけ気づいて、根本には触れない。これは、やまぶきに限らず、ありがちな失敗の形です。ソースコードを読めない相手であっても、外部から呼び出す関数の並びだけで、設計判断の輪郭がある程度は見えてくるという発見でもありました。

この問題は、awase固有のものでも、Windowsに固有のものでもありません。重い処理をしているタブを開いたまま他の操作をすると、ブラウザ全体が「応答なし」になって固まることがあります。多くのブラウザは画面の描画とクリックへの反応を一つのスレッドで順番にさばいており、どこか一箇所が時間をかけすぎると、他のすべての反応が止まります。スマートフォンのアプリでも同じ理由で警告が出ることがあり、Androidにはこれを検知する仕組み(ANR、Application Not Responding)が組み込まれています。決まった時間内に必ず制御を返すべき場所に、重い処理を持ち込んではいけないという制約です。

この個人的な経験は、後日のADR(Architecture Decision Record、設計判断の記録)にも、より広い形で裏付けられています。2026年3月30日夜のコミット2835bf6には、他の親指シフトエミュレータで報告されていた問題が整理されています。

問題他ソフトの状況awaseの対応
フック消失紅皿でPC高負荷時にキーボードがフリーズすると報告ハートビート監視、10秒無応答で警告
管理者権限プロセス紅皿v0.1.3で管理者昇格オプションが追加トレイに「管理者として再起動」を用意
キーボードレイアウト変更Windows Updateで106→101配列に変わる事例が多発起動時に確認し、変更イベントも監視
画面ロック/セッション切替やまぶきRでスリープ復帰後に動作しなくなる報告電源・セッション切替で状態を丸ごと引き直す
修飾キーのスタック残り調査した全エミュレータに共通する問題フォーカス変更時に押下状態を同期し直す
カタカナ/ひらがなキーロック型でキーアップが来ず、親指キーに使えないIMEガードとして扱えるキー数に上限を設ける

これは他ソフトの不具合を非難するための一覧ではありません。常駐して全キー入力を横取りするツールが、どこで壊れやすいかを先に洗い出した、失敗のカタログです。プロジェクト開始からわずか2日というタイミングで、同時打鍵の判定そのものより先にこの棚卸しが行われている点は見落とせません。やまぶきRで実際に手を止められた経験があったからこそ、この優先順位は自然に決まっていました。欲しかったのは変換の巧妙さより、日常的に使うツールが些細な不具合で入力不能に陥らないという安心感でした。

このツールには「awase」という名前が付けられました。最初のREADMEには、名前の由来が次のように書かれています。

awase (合わせ) remaps physical keys based on simultaneous keystroke detection — pressing a character key and a thumb key at the same time produces a different character, like playing a chord on a piano.

「合わせ」は、複数のキーを合わせて押すという動作そのものを指す名前です。技術的な仕組みの名前ではなく、指の動きの名前が選ばれている点は、このツールが最初から使う側の体感を軸に語られていたことを示しています。

この名前には、後からもう一つの理由が加わりました。プロジェクトの公式サイトがリニューアルされた際、「名前の由来」という説明が追加されています。「awase」をQWERTYキーボードで打つと、五文字は外側から内側へ流れるように打鍵できます。同時打鍵を「合わせる」という意味に加えて、指が美しく動く並びでもあった、という遊び心が、数か月たってから明かされたことになります。

最初に作ろうとした範囲は、NICOLA専用のツールではありませんでした。同時打鍵の判定は、前節で引用した設計文書が掲げる常駐型ツールの中でも、特に難しい一機能という扱いにすぎませんでした。同時打鍵の判定をどう表現するか、壊れたときに何を優先するか、といった具体的な設計は、まだ何も決まっていませんでした。実際に手を動かし始めてから、最初の壁にぶつかるまでにかかった時間は、ごくわずかでした。

1日で、信号を出すだけのフックができていた

開発を始めた2026年3月28日、コミットは21件記録されています。最初のコミット3444942から、実テキストを流し込むシナリオテストの4bfd034まで、その日のうちに一式がそろいました。

主なモジュール
捕捉hook(WH_KEYBOARD_LLフック)、RawKeyEvent/KeyAction(コア型)、scanmap
判定timed-fsm、engine(NICOLA同時打鍵状態機械)、kana_table、ngram
出力output(SendInputキー注入)、ime(TSF+IMM32検出)、platform traits、tray、main

これらは、思いつくままに書き足された名前の羅列ではありません。一つ一つが、入力から出力までの経路のどこかを担う部品です。捕捉するhook、判定を行うtimed-fsmとengine、出力するoutput・ime・trayという三つの層が、この21コミットの中に最初から並んでいました。

判定を行うengineはOSに依存しません。KeyboardHook・KeySender・ImeDetectorという抽象境界がその外側にあり、実際のWin32呼び出しはさらに外側のWindows実装だけに閉じていました。判定ロジックとOSとのやり取りを、最初から別の場所に置いていたということです。

キーボードフックには、前節で触れた時間の制約がありました。フックの中で時間のかかる処理を行うこと自体が、そもそも許されていません。hookモジュールの役目は、この制約から逆算して一つに絞られていました。押下・離上のイベントを拾い、タイムスタンプを付けて、次の層へ渡すだけです。同時打鍵かどうかの判定も、IME状態の操作も、フックコールバックの外側で行われます。フックは判断する場所ではなく、信号を出すだけの場所として設計されていました。

項目やまぶき4やまぶきRawase
フックが処理をブロックするかする(Sleep)する(Sleep)しない(即return)
IME操作の場所フック内(同期)フック内(同期)メッセージループ内(非同期)
フックの役目判定と操作の両方判定のみ信号を出すだけ

この設計判断が的外れではなかったことは、前節で触れたバイナリ解析によって後になって裏付けられました。従来ツールがフック内でブロッキングAPIを呼ぶ設計だったのに対し、awaseはフックを「信号を出して即returnするだけ」にし、IME操作をメッセージループ側へ委ねることで、再入・デッドロック・キー消失を構造的に排除しようとしました。

正しく変換された文字が初めて出力に現れた瞬間、動いたのはこの三段階でした。キーボードフックが拾った押下イベントはengineに渡り、判定結果はKeySenderを通じてWindowsに渡りました。その一式には判定ロジックだけでなく、SendInputによるキー注入・TSFとIMM32を組み合わせたIME状態検出・システムトレイアイコン・シナリオテストまで含まれていました。単なる思いつきの試作ではなく、すでに実運用を意識した骨格でした。

キーを受け取った後、どう手放すかにも選択肢がありました。一般的なキーボードフックには二つの動作方式があります。一つは、関係のあるキーだけを横取りし、それ以外はそのまま素通しするフィルター方式です。もう一つは、すべてのキーをいったん飲み込み、判定を終えてから自分で送り直すリレー方式です。二つの方式で何が変わるかは、図で見たほうが早いので、先に示します。

素通しでなく再注入だから、AutoHotKeyと併用しても入力が届き続ける

awaseが採ったのは後者でした。フィルター方式は他のフックソフトウェアとの実行順序次第で競合しうる一方、リレー方式はすべてのキーを一度FIFOキューに通すため、順序の保証が経路全体で一貫します。AutoHotKeyのような他のキーリマッパーと併用しても入力が届き続けるのは、この設計の副産物です。

同じ日のコミットには、やまぶき互換のレイアウトファイルを読み込むyab parserや、文字の出現頻度に応じて同時打鍵の許容幅を動かすngramモジュールも含まれていました。決め打ちの閾値一つだけで済ませず、人や文章によって打鍵の間隔が違うことを最初から前提にしていたということです。

SendInputで実際に何を送るかにも、選択肢がありました。NICOLA判定の結果は、ひらがな一文字として確定します。この一文字を下流のアプリケーションへ渡す方法には、大きく三つの案がありました。三つの案のうち二つが同じ弱点を抱えていたことを、次の図にまとめます。

ローマ字のVKコードなら、ひらがなも数字も記号も同じ経路で送れる

IMEをJISかな入力モードに切り替えて同じVKキーコードを送る案、ひらがなをローマ字へ逆変換してローマ字入力モードのままVKキーコードを送る案、そしてIMEを経由せず確定済みのひらがな文字そのものをUnicode文字として直接送り込む案です。採用されたのは二つ目でした。理由は、残る二つの案が同じ弱点を抱えていたことにあります。数字や記号の入力ではUnicode文字を送らざるを得ず、Unicode文字はIMEの変換候補に入らず確定済みの生の文字として扱われてしまい、Chromeとの相性問題がありました。ローマ字のVKキーコードであれば、ひらがなも数字も記号も同じ経路で送れ、この問題を構造的に避けられます。

ただしリレー方式には、それ自体が生む問題もありました。送り直したキーを、awase自身のフックがもう一度受け取ってしまえば、無限ループになります。この事故を避けるため、送り直すキーにはdwExtraInfoというWindowsが用意するフィールドに専用の目印を付け、フックの側で「これは自分が送ったものだ」と判別できるようにしました。この仕組みを持つhookモジュール(hook.rs)が最初に追加されたコミット39ed0c6は、timed-fsm追加コミット6776e94(21時17分09秒)の1秒後、2026年3月28日21時17分10秒でした。n-gramモデルの土台となるコミット6990a7dも、まったく同じ時刻に記録されています。信号を出すだけに絞ったフックの設計と、その信号を安全に往復させる仕組みと、統計的な判定補正の土台は、同じ瞬間に一つの塊として生まれていたことになります。timed-fsmという判定の土台をどう活かしたかは、第2章で扱います。

「動いた」翌日から、境界を引き直す作業が始まった

文字が正しく入ったことは、うれしい出来事でした。しかし、それは経路が一度、期待通りの入力に対して動いたという事実でしかありません。動いたコードは、正しく動く条件を教えてくれません。教えてくれるのは、次にどこが壊れそうか、という手がかりだけです。

開発を始めてから1日後、2日後、3日後にかけて、プロトタイプには次々と構造が足されていきました。新しい機能を足すためではなく、すでにある経路の内部の境界を引き直すための変更でした。

一つ目は、型を混ぜないための名前です。当時のADRには、仮想キーコード(VK)とスキャンコードが、どちらもu16として扱われ、関数シグネチャからは引数がVKコードなのかタイマーIDなのか区別できなかった、と整理されています。3月29日のコミットd86425bで適用が始まり、翌30日の314e3afで、VkCodeScanCodeというnewtype(同じ基本型でも意味の違う値を、別の型として区別する手法)が全面適用されました。以後、VKコードとスキャンコードの取り違えは、実行時ではなくコンパイル時に検出されるようになりました。バグを直したのではなく、そのバグが起こり得る余地そのものを、型の側から塞いだという違いがあります。

二つ目は、後始末を型に任せることです。Win32リソース(キーボードフック、ホットキー、タイマー、トレイアイコン、WinEventフック)は、それまで手動でcleanup()の中で解放していました。呼び忘れの懸念に加え、パニック時にクリーンアップが保証されないという懸念もありました。newtypeの全面適用と同じ3月30日、コミットabd43d8で資源ごとにガード型が導入されます。これは一般にRAII(Resource Acquisition Is Initialization)と呼ばれる手法です。

ガード対象Dropで呼ぶ処理
HookGuardキーボードフックUnhookWindowsHookEx
HotKeyGuardホットキーUnregisterHotKey
TimerGuardタイマーKillTimer
WinEventHookGuardWinEventフックUnhookWinEvent
SystemTray(Drop実装)トレイアイコンShell_NotifyIconW(NIM_DELETE)

値が作られた瞬間に資源を確保し、値が不要になった瞬間、破棄(Drop)にあわせて資源を自動的に解放します。解放を呼び忘れるという判断を、人間の注意力ではなく、値の寿命そのものに委ねたということです。この考え方自体はRustやC++に閉じたものではなく、Pythonのwith文も、ブロックを抜ける瞬間に確保した資源を自動的に閉じる同じ約束を実現しています。

三つ目は、判断と実行を分けることです。engineの内部はもともと副作用を宣言的な値として返す作りでしたが、その外側(IMEガード、特殊キー判定、IME制御)は、Win32 APIを直接呼び出す命令的なスタイルのままでした。同じ3月30日、コミット6543603で外側もengineに合わせて書き直され、2332c94でADRとして文書化されます。

Engine::on_input(event, ctx) → Decision   (純粋な判断のみ)
AppState::execute_decision(decision)      (副作用の実行はここだけ)

判断を返す層と、副作用を実行する層を、それぞれ一つに絞ったということです。この整理だけでmain.rsとAppStateの実装は300行以上短くなったとADRに記録されています。ここでいう「判断」は、SendKeys(キーを送る)、SetTimer(タイマーを仕掛ける)、SetImeOpen(IMEのON/OFFを切り替える)といった具体的な操作の列(Effect)でした。SendInputのような操作は実際には数十ミリ秒かかることがあり、フックの時間の制約に対して無視できない長さでした。そこでEffectはその場で実行されず、時間の制約を受けないメッセージループ側で一つずつ実行されます。

判断を値として書き出し、実行を別の場所に任せるという考え方自体は、awase固有のものではありません。メールソフトで「送信」を押した瞬間にメールが直接ネットワークへ流れるのではなく、いったん送信トレイに置かれてから、バックグラウンドの処理が順番に送り出すのも同じ構造です。ソフトウェア設計の分野では、判断を行う中心部を副作用のない純粋な関数にまとめる設計が「Functional Core, Imperative Shell」と呼ばれます。awaseの工夫は、この発想をWindowsフックという具体的な時間制約へ当てはめた点にありました。

この分離は、テストのしやすさにも直結していました。engineが返すのはEffectという値の列でしかないため、本物のキーボードフックやIMEを用意しなくても、正しいEffectの列が返ってくるかだけで判断ロジックを検証できます。実機で手打ちして確かめる数分と、テスト一回あたり数秒の差は、一日に試せる変更の回数に直接効いてきます。その翌日には、判断だけでなく観測の側も、責務ごとに層を分け直す改修が続きました。

型を分け、資源の寿命を型に委ね、判断と実行を分ける。三つの変更に共通していたのは、「たまたま今回は大丈夫だった」という状態をそのままにはしなかったという姿勢です。型も資源の解放も、副作用の呼び出し口の少なさも、たまたまそうだっただけでした。動いた経路を、動いた理由の側から一つずつ点検し直した数日だったと言えます。

この経路が答えていないのは、タイミングという一つの物差しだけで、同時打鍵かどうかをいつも判定し切れるのか、という問いです。


設計原則

原則: 常駐して全キー入力を横取りし、加工してから相手に渡すツールでは、「正しく変換する」ことより「入力を失わない」こと、そして「壊れる場所を狭く保つ」ことを優先します。

適用条件: すべてのキー入力を横取りする常駐型ツール全般に働きます。特に、時間の制約がある場所(フックコールバックのような場所)で判断や実行まで行おうとする場面で強く働きます。

実装の形: 実装の形は三段階です。正常時は配列変換を適用します。異常を検知した時点では、変換を諦めて元の入力をそのまま通します(PassThroughフォールバック)。重大な異常が続く場合は、変換そのものを自動停止し、トレイ通知でユーザーに知らせます。どの段階でも、キーボードから入力そのものが失われることはなく、再開はユーザーの明示的な操作を待ちます。これに加えて、時間の制約がある場所は信号を出すだけに絞って判断と実行を別の場所に置き、同じ基本型に複数の意味が乗っている値はnewtypeで分け、確保と解放が対になる資源はガード型に包みます。

限界: この構造が防ぐのは、内部の責務が混ざることと、不具合時に入力そのものが失われることだけです。同時打鍵の判定がどれだけ的確か、外部から届く観測が信頼できるかどうかは、この構造だけでは何も保証しません。