絵文字入力メソッドに絵文字候補の表示・選択を追加した。やったことと、IMKCandidatesクラスのドキュメントと挙動の差異について書く。
📦コード
#8 Show candidate using IMKCandidates
📝IMKCandidatesクラスの使い方
InputMethodKitのIMKCandidatesを用いると、変換候補ウインドウを表示できる。
変換候補ウインドウ表示
show()
メソッドを用いると、変換候補ウインドウを表示できる。
let candidates: IMKCandidates = IMKCandidates( server: server, panelType: kIMKSingleColumnScrollingCandidatePanel, styleType: kIMKMain) candidates.show()
ウインドウの形状は、初期化時に渡す panelType:
引数で指定する
kIMKSingleColumnScrollingCandidatePanel
縦一列。 
kIMKSingleRowSteppingCandidatePanel
横一列。

kIMKScrollingGridCandidatePanel
グリッド状の表示。

変換候補の取得
IMKCandidates
のupdate()
を呼ぶと、 IMKInputController
の candidates(:)
kから変換候補が読み込まれる。
そのため、 入力コントローラで以下の実装を行なうとa, b, cの3つの変換候補が表示される。
open override func candidates(_ sender: Any!) -> [Any]! { return ["a", "b", "c"] }
候補ウインドウの操作
候補ウインドウは矢印キーなどで候補選択を移動できる。これは入力コントローラが受けとったキー入力イベントを候補ウインドウに渡すことで実現できる。
class EmojiInputController: IMKInputController { ... open override func handle(_ event: NSEvent!, client sender: Any!) -> Bool { if /* 候補ウインドウが表示されているならば…. */ { candidates.interpretKeyEvents([event]) return true } //別の処理 } ... }
変換候補の選択
候補選択が移動された場合や、選択が決定された場合はIMKInputController
のcandidateSelectionChanged(:)
や candidateSelected(:)
が呼ばれる。 そのため、これらのメソッドをオーバーライドすることで候補ウインドウの変化を受け取れる。
open override func candidateSelectionChanged(_ candidateString: NSAttributedString!) { // 候補選択が移動された } open override func candidateSelected(_ candidateString: NSAttributedString!) { // 候補選択が決定された }
初期状態では候補選択が決定されたのちに、候補ウインドウが非表示になる。 この動作はsetDismissesAutomatically(_:)
メソッドで変更できる。
✨絵文字入力メソッドの拡張
変換候補の表示・選択に対応するために、絵文字入力メソッドを拡張する。
- 入力中状態の間は、絵文字の候補を表示する。
- 入力中状態で←キなどの操作キーが押されたら、候補選択状態に遷移する。
- 候補が選択されたら、候補選択状態から初期状態に遷移する。
状態遷移図は以下のようになる。変換候補の表示と関係ない箇所は破線にした。

操作キー、候補選択の追加
操作キーと候補選択を表現できるよう入力の種類を追加する。
enum EventType { case input(text: String) case enter case colon case navigation // <- NEW case selected(candidate: String) // <- NEW }
操作キーはキー入力イベントとの対応を定義する。 簡単のため、アルファベット、数字、記号以外のキーを操作キーとみなす。
open class EmojiInputController: IMKInputController { ... private func convert(event: NSEvent) -> EventType? { if event.keyCode == 36 { return .enter } else if let text = event.characters { switch text { case ":": return .colon default: if !text.unicodeScalars.contains { !printable.contains($0) } { return .input(text: text) } else { return .navigation } } } else { return nil } } ... }
候補選択状態の追加
変換候補を選択中である状態を追加する。
public enum InputMethodState { case normal case composing case selection // <- NEW }
候補選択状態への遷移
入力中状態から候補選択状態への遷移を追加する。
public class EmojiAutomaton { init() { let mappings: [ActionMapping<InputMethodState, UserInput>] = [ /* Input <|> fromState => toState <|> action */ /* -------------------------------------------*/ … typeof(.navigation) <|> .composing => .selection, isSelected <|> .selection => .normal, { _ in true } <|> .selection => .selection ] ...
変換候補の取得
変換候補を検索できるよう絵文字辞書を追加する。
class EmojiDictionary { func find(prefix: String) -> [String] { return …… // prefixではじまる絵文字を返す } }
状態遷移機械に変換候補を保持するReactiveSwiftのPropertyを追加する。これは変換候補を文字列の配列として格納する。
public class EmojiAutomaton { let candidates: Property<[String]> init() { let candidatesProperty = MutableProperty<[String]>([]) self.candidates = Property(candidatesProperty) ... }
入力したテキストに応じて、変換候補が更新するよう状態遷移を変更する。
public class EmojiAutomaton { init() { let mappings: [ActionMapping<InputMethodState, UserInput>] = [ /* Input <|> fromState => toState <|> action */ /* -------------------------------------------*/ ... UserInput.isInput <|> .composing => .composing <|> { $0.ifInput { text in markedTextProperty.modify { $0.append(text) } candidatesProperty.swap(dictionary.find(prefix: text)) // <- NEW } }, UserInput.typeof(.enter) <|> .composing => .normal <|> { _ in textObserver.send(value: markedTextProperty.value) markedTextProperty.swap("") candidatesProperty.swap([]) // <- NEW }, UserInput.isSelected <|> .selection => .normal <|> { candidatesProperty.swap([]) }, ... ] ...
変換候補の更新が画面に反映するよう入力コントローラを変更する。
open class EmojiInputController: IMKInputController { public override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { …. automaton.candidates.signal.observeValues { if $0.isEmpty { self.candidates.hide() } else { self.candidates.update() self.candidates.show() } } } open override func candidates(_ sender: Any!) -> [Any] { return automaton.candidates.value }
イベントの転送
候補選択状態の間は、キー入力を IMKCandidates
に転送する必要がある。 そのため、候補ウインドウに転送する NSEvent
を流す Signal
を追加する。
public class EmojiAutomaton { init() { let candidateEvent: Signal<NSEvent, NoError> init() { let (candidateEvent, candidateEventObserver) = Signal<NSEvent, NoError>.pipe() self.candidateEvent = candidateEvent
状態遷移のアクションを変更し、候補選択状態の場合は、この Signal
にイベントを流す。
public class EmojiAutomaton { init() { let mappings: [ActionMapping<InputMethodState, UserInput>] = [ /* Input <|> fromState => toState <|> action */ /* -------------------------------------------*/ ... , { _ in true } <|> .selection => .selection <|> { _ = $0.originalEvent.map { candidateEventObserver.send(value: $0) } } ]
入力コントローラで、このSignalを監視し、候補ウインドウに転送する。
open class EmojiInputController: IMKInputController { public override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { ... automaton.candidateEvent.observeValues { self.candidates.interpretKeyEvents([$0]) } }
候補の選択
候補が選択された場合、その候補がテキストとして入力されるよう状態遷移を変更する。
public class EmojiAutomaton { init() { let mappings: [ActionMapping<InputMethodState, UserInput>] = [ /* Input <|> fromState => toState <|> action */ /* -------------------------------------------*/ ... isSelected <|> .selection => .normal <|> { $0.ifSelected { textObserver.send(value: $0) } markedTextProperty.swap("") candidatesProperty.swap([]) } ]
入力コントローラを変更し、候補選択時に状態遷移機械へ入力を与える。ただし、ReactvieSwiftのオペレータは再入可能ではないので、現在の遷移が完了してから入力を与えるようにする。
open class EmojiInputController: IMKInputController { open override func candidateSelected(_ candidateString: NSAttributedString!) { DispatchQueue.main.async { _ = self.automaton.handle(UserInput(eventType: .selected(candidate: candidateString.string))) } }
⚠️ 動作しないメソッド
IMKCandidates
クラスの持ついくつかのメソッドは動作しない。(macOS HighSierraで確認)
アノテーション用のメソッド:
- func showAnnotation(NSAttributedString!)
キー操作以外で候補を選択するためのメソッド郡:
- func candidateIdentifier(atLineNumber: Int)
- func candidateStringIdentifier(Any!)
- func lineNumberForCandidate(withIdentifier: Int)
- func selectCandidate(Int)
- func selectCandidate(withIdentifier: Int)
- func clearSelection()
その他:
- func attachChild(IMKCandidates!, toCandidate: Int, type: IMKStyleType)
- func showChild()
- func showSublist([Any]!, subListDelegate: Any!)
- func detachChild(Int)
- func hideChild()
詳細は(rdar://34944196およびrdar://34911503)に書いたが、これらのメソッドはなにもしない、もしくは定数を返すメソッドとして実装されている。 無である。