skip to content
私的歌詞倉庫

macで動作する英語向けIMEツールRaelizeを開発した

/ 24 min read

Updated:
Table of Contents

はじめに

Raelize(読み: れりーず)というmacで動作する英語向けIMEを少しずつ開発していたのですが、ようやくある程度動くようになったので、自慢させて下さい!

名前の由来は大好きなアイドルであるaespaのaeと、RIIZEを組み合わせた造語です。無理やり組み合わせて作りました(この前のSMTOWNでaespaとRIIZE見たけど最高でした🥺)。

aespa(エスパ)JAPAN OFFICIAL WEBSITE

RIIZE

RaelizeはThe Composable Architecture(TCA)InputMethodKitfastlaneswift-testingなどを使いました。

開発のモチベーションとしては、

  • 英単語の入力ムズイ
  • 最近Ruby、PHP、JavaScriptとか多いからSwift書きたい
    • Combine触りたい
    • どうせだったら勉強のためにTCA使いたい
    • swift-testingこれから来そうだからこれ使ってテスト書きたい
    • マルチモジュール構成もやりたいな…

こんなゆるい気持ちでした。一番最初の方はLLM(llama.cpp)を使ったIMEを作ろうとしたのですが、動作が激重で諦めました…

この記事では、そんな紆余曲折あったRaelizeの紹介からInputMethodKitの使い方、どうやってTCAを組み合わせて使ったかなどについて紹介します。

実際のコードはこちらにあります。

Lorem ipsum dolor sit amet, consectetur adipiscing elit.
00K00KMIT

Raelize

入力した英語にマッチする英単語を補完するmac向けIMEです。

ロジックとしては、あらかじめ英単語の先頭の文字(a~z、数字、記号)に応じてtsvファイルを分けて準備しておきます(実際のコードはこちら)。このときtsvファイルの中身は辞書順に並べています(Numbersを使った手作業です👼)。

ユーザの入力を受け取って該当のファイルを開き、二分探索してマッチしたものがあったら、それを返すだけです。そのため、文脈などは一切考慮しません。単純に英単語を探すだけです。

英単語リストはwiktionaryを使いました。ライセンスも再配布など特に問題なさそうだったのでこれを採用しました。

Wiktionary is a wiki, which means that you can edit it, and all the content is dual-licensed under both the Creative Commons Attribution-ShareAlike 4.0 International License and the GNU Free Documentation License. Before you contribute, you may wish to read through some of our help pages, and bear in mind that we do things quite differently from other wikis. In particular, we have strict layout conventions and inclusion criteria. Learn how to start a page, how to edit entries, experiment in the sandbox and visit our Community Portal to see how you can participate in the development of Wiktionary.

tsvファイルがそこそこの容量になりそうだったので、Raelize本体のgit submoduleとして管理しました。

実装したアーキテクチャ

Raelizeでは補完候補を出したりするためにInputMethodKit、アーキテクチャとしてはTCAとRepositoryパターンを組み合わせたレイヤードアーキテクチャで開発しています。

TCAはなんとなく勉強のために使ってみるか〜で使いました。

Repositoryパターンも加えた理由は探索ロジックに関しては、別に分けることであとから他のアーキテクチャへの乗り換えをしやすくしたり、ロジック部分をモック化しやすい形にすることで、テストを書きやすくできるかなと思ったためです。

自分がAndroid開発が好きというのもあって、Google公式のアプリアーキテクチャガイドがこの書き方を推奨してるので癖になってるというのもあります(今回だとDomain Layerと一部のUI LayerがTCAのInputMethodKitで、Repositoryパターンの箇所がData LayerとDomain Layerかなと思います…多分)。

単純にRepositoryパターンを実装するのではなく、データ(tsvファイル)にアクセスするだけのRepository層と、そのデータを加工する(探索)UseCase層に分解しています(実際のコードはこちら)。こうすることで、ファイルの読み込み対象が変わってもRepository層だけ修正すれば済むため、変更に強くなると思っています。

Repository層でしているファイルの読み込み処理は非常にコストがかかるので、一度開いたらメンバ変数に作ったCurrentValueSubjectに値を持たせます(実際のコストはこちら)。そのおかげか、全然もたつきもなく快適に動作します。

Swift Package Manager(SPM)を使ったマルチモジュール構成も採用しました。モバイル開発をするときはAndroidもiOSも多い気がするので採用しました。

とりあえず何も考えずにFeaturesディレクトリを作って、その中にモジュールたちを突っ込みました。

Xcodeのナビゲーション

Xcodeのナビゲーション

InputMethodKitはデバッグが面倒(その話はこちら)なので、SwiftUIで作ったRaelizeDebugというアプリも作りました。Debugの方はTextFieldとボタンを持っているだけのアプリでRaelizeLogicのデバッグを簡単にするために作りました。

結果的に効果絶大で検証が楽になりました。これもマルチモジュール構成のメリットだなと実感しました。

Raelizeの構成図

Raelizeの構成図

InputMethodKit

mac向けのIMEを作るために使うフレームワークです。Swiftで作るなら実質これを使うしかないような気がします。RubyだったらGyaimというのを使って開発もできるそうですが、Swiftで書きたいのでInputMethodKitを使いました。

InputMethodKitについては日本語入力を作るときに必要だった本が非常に参考になりました。

日本語入力を作るときに必要だった本

コードはこちらが非常に参考になりました(本で解説されているコード)。

GitHub - mzp/EmojiIM: 😀 masOS Emoji Input Method

その他の参考になったサイトについては、最後に参考文献としてまとめます。

次はInputMethodKitにある3つのクラスを使ってロジックやUIを実装したので、それらについて紹介します。

IMKCandinates

補完候補のウィンドウを操作するために使います。デフォルトでUIの表示方法が定義されてるので、自分はそれを使いました。このクラスを継承していい感じに独自のウィンドウを作ることもできるっぽいですが、難しそうなので自分は諦めました…(デフォルトでも十分でした)

後述するIMKInputControllerを継承したクラスのイニシャライザで、初期化して使っています(実際のコードはこちら)。

/// メンバ変数で定義しておいて…
private let candidates: IMKCandidates
/// イニシャライザで初期化
self.candidates = IMKCandidates(
server: server, // IMKServer
panelType: kIMKSingleColumnScrollingCandidatePanel // UIの種類
)

panelType にUIの種類を設定します。3つ種類があってこちらにドキュメントがあります。

今回使ったkIMKSingleColumnScrollingCandidatePanel はよくある縦型表示です。

IMKCandinatesで個人的によく使うメソッドは、補完ウィンドウを操作するupdateshowhideです(実際のコードはこちら)。メソッド名のままの処理をしてくれます。

メソッド名内容
update補完ウィンドウの内容を更新
show補完ウィンドウを表示
hide補完ウィンドウを非表示

自分はupdateとshowは一緒に使います。ユーザの入力内容を受けて補完候補を決定したら、updateでウィンドウの内容をアップデートして、showでウィンドウ自体を表示します(updateでは更新するだけでここで直接内容を決めるわけでないです。後述するcandidates(_ sender: Any!) -> [Any]! で内容を決めます)。

一応updateだけでもウィンドウの内容は変わってくれそうなのですが、ウィンドウが非表示(hide)の時にupdateを呼び出してもウィンドウは表示しません。なので、updateとshowを一緒に呼び出すようにしています。

ユーザの入力で補完候補がない場合やIME自体を終了した時はhideで非表示にします。

IMKServer

キー入力とアプリを仲介するクラスです。このクラスは特に継承などをせずにそのまま呼び出せばいいです。大体のアプリはAppDelegateで初期化すると思います(実際のコードはこちら)。

@main
class AppDelegate: NSObject, NSApplicationDelegate {
var server = IMKServer()
func applicationDidFinishLaunching(_ notification: Notification) {
server = IMKServer(
name: , Bundle.main.infoDictionary?["InputMethodConnectionName"] as? String // サーバの名前
bundleIdentifier: Bundle.main.bundleIdentifier// BundleID
)
}
}

使うためには、Swiftのコード以外にもInfo.plistに設定が必要です。

Info.plistのInputMethodConnectionName にこのコネクション名(IMKServer自体の名前)を入れます。文字列かつ重複しないならなんでもいいっぽいです。

nameの引数に渡すことで、InputMethodKit自体がこのコネクション名を使ってクライアント(他のアプリ)からの接続を待ち受けて色々処理をしていくという流れのようでした(自信ない)。

IMKInputController

ここが一番コードの記述量が多くなってきます。

IMKInputControllerを継承してユーザの操作に応じて必要なメソッドにロジックを書きます(実際のコードはこちら)。

イニシャライザでTCAのStoreの初期化やStateの監視を行いました(実際のコードはこちら)。

// メンバ変数たち
private let candidates: IMKCandidates
private let store: StoreOf<RaelizeIMKReducer>
public override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) {
self.candidates = IMKCandidates(
server: server, panelType: kIMKSingleColumnScrollingCandidatePanel)
self.store = Store(
initialState: RaelizeIMKReducer.State(raelizeState: .inputMode),
reducer: { RaelizeIMKReducer() })
super.init(server: server, delegate: delegate, client: inputClient)
guard let client = inputClient as? IMKTextInput else {
return
}
let notFound = NSRange(location: NSNotFound, length: NSNotFound)
observe {
if self.store.candinates.isEmpty || self.store.inputWord.isEmpty {
self.candidates.hide()
} else {
self.candidates.update()
self.candidates.show()
}
}
observe {
client.insertText(
self.store.insertText,
replacementRange: NSRange(location: self.store.insertText.count, length: 0))
}
observe {
client.setMarkedText(
self.store.inputWord,
selectionRange: NSRange(location: self.store.inputWord.count, length: 0),
replacementRange: notFound)
}
observe {
if let candidateEvent = self.store.candidateEvent {
self.candidates.interpretKeyEvents([candidateEvent])
}
}
}

Raelizeを実装するにあたって、次にIMKInputControllerの中で今回使ったメソッドについて紹介します。

handle(_ event: NSEvent!, client sender: Any!) -> Bool

このメソッドはキーを押下したときやマウスをクリックした時に呼ばれます。IMKInputControllerのメソッドというよりは、IMKInputControllerが継承しているNSObjectのメソッドで、イベントを処理したかどうかをBoolで返します(実際のコードはこちら)。

TCAのStateにRaelize自体の状態を3つ定義してそれに応じてBoolを返すようにしました(先ほど紹介した本がそのようになっていたので参考にさせていただきました)。

public override func handle(_ event: NSEvent!, client sender: Any!) -> Bool {
guard let event = event else { return false }
// 受け取ったイベントに応じて状態を切り替えるActionを呼び出す
self.store.send(.handleRaelizeState(event))
switch self.store.raelizeState {
case .neutralMode: // 何もないモード
return false
case .inputMode, .operationMode: // 文字入力or補完ウィンドウ操作モード
return true
}
}

eventの中にkeyCodeという変数があるのでここでキー入力を判断できます(keyCodeのリストは多分これです)。

例えば、バックスペースキーの処理を無効化したければ、このメソッドの中で、keyCodeが51だったら返り値でfalseを返せばその処理を無効化できます。

もちろん文字列もeventの中にはあって、charactersという変数に入っています。

specialKeyという変数もあって、あらかじめenterやbackspaceなどの特殊キーがわかりやすい形で構造体として定義されているのですが、使いたいキーが定義されてないので、自分はkeyCodeを地道に使うようにしてます(探したけどspaceがないっぽいんですよね…)。

keyCodeはただのUInt16型でパッと見でわかりにくいので変換器を自作して使ってます(実際のコードはこちら)。

public struct KeyEvent {
/// NSEvent.keyCode
private let keyCode: Int
public init(keyCode: Int) {
self.keyCode = keyCode
}
/// NSEvent.keyCode's name
public enum EventName: Int {
case enter = 36
case backspace = 51
case upArrow = 126
case downArrow = 125
case tab = 48
case undifined = -1
}
/// keyCode -> key name
public var eventName: EventName {
return EventName(rawValue: keyCode) ?? .undifined
}
}
// 使い方
let keyCode = KeyEvent(keyCode: Int(event.keyCode))
if keyCode.eventName != .undifined {
// 必要な処理をする
}

UInt16型で-1は入り得ないので未定義(undifined)としてenumを作って、EventName型のeventName変数で変換して使ってます。他の特殊キーも処理したい場合はEventNameに必要なkeyCodeの番号を追加していくだけで使えます。

これ結構オシャレな書き方だなと自画自賛しています。

candidates(_ sender: Any!) -> [Any]!

補完ウィンドウに表示する内容を返り値で返します。

自分はTCAを使ってるのでStoreのStateを返すだけにしています(実際のコードはこちら)。

public override func candidates(_ sender: Any!) -> [Any]! {
// candinates自体は[String]型です
return self.store.candinates
}

candidateSelected(_ candidateString: NSAttributedString!)

補完ウィンドウを選択した(決定)時に呼ばれます。

public override func candidateSelected(_ candidateString: NSAttributedString!) {
self.store.send(.insertText(candidateString.string))
}

candidateString.stringに補完ウィンドウで選んだ文字列が入ってくるのでTCAのActionに渡して、入力内容を決めます。先ほど紹介したIMKInputControllerのイニシャライザでinsertTextをobserve(監視)しているので、文字列をIMKTextInputのinsertTextメソッドを使って置換します。

candidateSelectionChanged(_ candidateString: NSAttributedString!)

ここでは補完ウィンドウの選択肢を切り替えたとき(決定ではない)呼ばれます。

public override func candidateSelectionChanged(_ candidateString: NSAttributedString!) {
guard let candidateString = candidateString else { return }
self.store.send(.selectedWord(candidateString.string))
}

ここもTCAのActionを呼び出してます。このActionは、candidateSelectionChangedが呼ばれた = ユーザは入力ではなく補完ウィンドウを操作しているので、TCAのStateを補完ウィンドウ操作モードに切り替えるだけのActionです。

deactivateServer(_ sender: Any)

Input Method Serverがアクティベートじゃなくなった時に呼ばれます。

public override func deactivateServer(_ sender: Any) {
self.candidates.hide()
self.store.send(.resetState(.neutralMode))
}

補完ウィンドウを隠して、TCAのStateを初期化する処理を呼び出します。hideしないと永遠に補完ウィンドウが画面に残り続けて邪魔なので消してあげます。

TCA

TCAを開発するときはSwiftUIやUIKitと一緒に使うことがほとんどだと思う(というか自分はそれしか見たことない)のですが、意外とそれ以外の開発に使えるということを実感しました。

TCAを使うことで、InputMethodKitのIMKInputController内にロジックをほぼ書かなくてよくなります。そのためテストを書きやすくなったり、コードの見通しがよくなるという利点がありました。

UseCase層もただ呼び出すだけではなく、テストしやすくするためにもTCAにある@Dependency を使ってDIするようにしました。

private enum WordListFileUseCaseKey: DependencyKey {
// DIしたいインスタンスをDependencyKeyを継承したクラスのliveValueに登録
static let liveValue: any WordListFileUseCaseType = UseCaseProvider.shared.wordListFileUseCase
}
// DependencyValuesを拡張
extension DependencyValues {
// 登録したDI対象のインスタンスを使うためのキーを設定
public var wordListFileUseCase: any WordListFileUseCaseType {
// インスタンスを公開
get { self[WordListFileUseCaseKey.self] }
set { self[WordListFileUseCaseKey.self] = newValue }
}
}
// 呼び出すとき
@Dependency(\.wordListFileUseCase) private var wordListFileUseCase

シングルトンにするためにsharedオブジェクトを作ったりしていたのですが、これを使えば勝手にシングルトンになるのでいいですね。

黒魔術感はありますが、Dagger Hiltに比べてやることも設定も少なくていい感じです(できることの幅が違うというのもありますが…)。今度どうやっているかコードも見てみたいです。

Test用のDI設定もあるようですが途中で燃え尽きて自分はやりませんでした。

その他

swift-formatの導入もしました。毎回手動で整形するのも大変なので、git commit時に差分のあるファイルのみ自動で整形するpre-commitを作りました(実際のコードはこちら)。

#!/bin/bash
STAGED_FILES=$(git diff --diff-filter=d --staged --name-only)
echo "$STAGED_FILES" | grep -e '.swift$' | while read -r file; do
echo "Formatting ${file}"
swift-format "${file}" -i
git add "${file}"
done
## 使い方
git config --local core.hooksPath .githooks
chmod +x .githooks/pre-commit

xcconfigを使ってXcode設定を簡単に書き換えれるようにもしました。チームIDなどをいちいちXcode上から書き換えるのは面倒だと思うので、直接ファイルを書き換えれるようにファイルを作りました(実際のコードはこちら)。

fastlaneやswift-testingも使ったのですがそちらについては別記事に書きました。

fastlaneを使ってmacアプリをビルドする

The Composable Architectureのテストを書く swift-testingも添えて

細かい点としては、commitメッセージからREADMEまで全部英語でチャレンジしてみました。というのも、とあるOSSを使う時にREADMEからコード内のコメントまで中国語のものがあって、途中で心が折れてしまったという経験がありました。きっと英語話者からしたら、日本語もこんな感じなんだろうなと思い、とりあえず分からなくても英語で書きました。

終わりに

macで動作する英語向けIME、Raelizeについて紹介しました。

InputMethodKitという滅多に触らない技術を触ってある程度形になるものができて楽しかったです。TCAもよく考えられているフレームワークだな〜と思いました。ReactorKitを使ってた身としては、書き心地も似ていて楽しかったです。

単語のリストが微妙なのか超使える!というものでもないですし、バックスペースキーを押し続けたりするとクラッシュしたりします。そのため今後も色々改良は必要そうです。

参考文献