みずぴー日記

月に行こうという目標があったから、アポロは月に行けた。飛行機を改良した結果、月に行けたわけではない。

⌨️キーボード配列の取得

入力メソッドはキー入力イベントをテキスト入力に変換する。

キーボードのキーが押されたときに、どのキー入力イベントが発生するかはmacOSの設定によって変更できる。 通常はキーに印字されている文字と一致するようにQwerty配列が用いられるが、Dvorak配列などのそれ以外の配列にも変更できる。

そのためmacOS標準の日本語入力では、どのキーボード配列を使うかを設定できる。

f:id:mzp:20171124114611p:plain

🐙ソースコード

💎TISInputSource

利用可能なキーボードを取得する関数は、Carbon frameworkのHIToolboxで提供される。HIToolboxではキーボード配列や入力メソッドなどを総称して入力ソース(InputSource)と呼ぶ。

TISCreateInputSourceList は条件に合致したものを入力ソース取得する。 入力メソッドで使えるキーボードを取得するには種別がキーボード配列であり、ASCII文字を入力できる入力ソースを取得すればよい。

let conditions = CFDictionaryCreateMutable(nil, 2, nil, nil)
CFDictionaryAddValue(conditions,
                     unsafeBitCast(kTISPropertyInputSourceType, to: UnsafeRawPointer.self),
                     unsafeBitCast(kTISTypeKeyboardLayout, to: UnsafeRawPointer.self))
CFDictionaryAddValue(conditions,
                     unsafeBitCast(kTISPropertyInputSourceIsASCIICapable, to: UnsafeRawPointer.self),
                     unsafeBitCast(kCFBooleanTrue, to: UnsafeRawPointer.self))

guard let array = TISCreateInputSourceList(conditions, true) else {
       return nil
}
return array.takeRetainedValue() as? [TISInputSource]

取得したキーボード配列の属性はTISGetInputSourcePropertyで取得する。

// 入力ソースのIDを取得する
TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID)

// キーボード配列の名前を取得する
TISGetInputSourceProperty(inputSource, kTISPropertyLocalizedName)

🙊キーボード配列の種類

ASCII文字が入力可能なキーボード配列は2種類存在する。

1種類目はDvorak、ColemarkのようなQwertyと同様のキーを持ちながら配置のみが違うものである。macOSはこれらは英語入力用の配列として扱う。

f:id:mzp:20171124115052p:plain

もう1種類は、フランス語キーボードのようにùなどの特別な文字を入力できるキーボードである。これらは英語以外のキーボードとして扱う。

f:id:mzp:20171124115042p:plain

この2種類のキーボード配列を区別する方法は公開されていない。しかし、TSMInputSourcePropertyScriptCodeプロパティを取得すれば可能である。このプロパティは日本語入力の設定画面のコードから発見した。

let r = TISGetInputSourceProperty(inputSource, "TSMInputSourcePropertyScriptCode" as CFString)
let n = unsafeBitCast(r, to: NSInteger.self)

// 39なら英語
return n == 39

💄設定画面

HIToolboxのままだと、CoreFoundationの型が必要になるなど、扱いが面倒である。 そこで TISInputSource を拡張し、Swiftからも利用しやすくする。

extension TISInputSource {
    var localizedName: String {
        return unsafeBitCast(
            TISGetInputSourceProperty(self, kTISPropertyLocalizedName),
            to: NSString.self) as String
    }

    var inputSourceID: String {
        return unsafeBitCast(
            TISGetInputSourceProperty(self, kTISPropertyInputSourceID),
            to: NSString.self) as String
    }

    var scriptCode: Int? {
        let r = TISGetInputSourceProperty(self, "TSMInputSourcePropertyScriptCode" as CFString)
        let n = unsafeBitCast(r, to: NSInteger.self)
        return n
    }

    class func keyboardLayouts() -> [TISInputSource]? {
        let conditions = CFDictionaryCreateMutable(nil, 2, nil, nil)
        CFDictionaryAddValue(conditions,
                             unsafeBitCast(kTISPropertyInputSourceType, to: UnsafeRawPointer.self),
                             unsafeBitCast(kTISTypeKeyboardLayout, to: UnsafeRawPointer.self))
        CFDictionaryAddValue(conditions,
                             unsafeBitCast(kTISPropertyInputSourceIsASCIICapable, to: UnsafeRawPointer.self),
                             unsafeBitCast(kCFBooleanTrue, to: UnsafeRawPointer.self))

        guard let array = TISCreateInputSourceList(conditions, true) else {
            return nil
        }
        guard let keyboards = array.takeRetainedValue() as? [TISInputSource] else {
            return nil
        }
        return keyboards.sorted {
            $0.localizedName < $1.localizedName
        }
    }
}

入力メソッドの設定画面で作成した設定画面にキーボード配列選択のためのポップアップを追加する。

public class Preferences: NSPreferencePane {
    private let store: SettingStore = SettingStore()
    private lazy var keyboardLayouts: [TISInputSource]? = TISInputSource.keyboardLayouts()?.filter { $0.scriptCode == 39 }

    override public func mainViewDidLoad() {
        let keyboard = NSPopUpButton() ※ {
            for layout in keyboardLayouts ?? [] {
                $0.addItem(withTitle: layout.localizedName)
            }
        }
        …
    }
}

選択したキーボード配列をNSUserDefaultに保存するように変更する。さらに起動時に保存した内容を読み込むようにする。

keyboard.reactive.selectedIndexes.observeValues {
        if let layout = self.keyboardLayouts?[$0] {
                self.store.setKeyboardLayout(inputSourceID: layout.inputSourceID)
        }
}
if let index = keyboardLayouts?.index(where: { $0.inputSourceID == store.keyboardLayout() }) {
       keyboard.selectItem(at: index)
}

📝 入力メソッド

入力メソッドでキーボード配列を指定するには IMKTextInputoverrideKeyboard(withKeyboardNamed:) を用いる。

クライアントアプリケーションが切り替わったときに呼ばれる activateServer と入力モードが切り替わったときに呼ばれる setValue(_:, forTag:,client) でキーボード配列を指定する。

extension EmojiInputController /* IMKStateSetting*/ {
    override func activateServer(_ sender: Any) {
        NSLog("%@", "\(#function)((\(sender))")

        guard let client = sender as? IMKTextInput else {
            return
        }
        client.overrideKeyboard(withKeyboardNamed: SettingStore().keyboardLayout())
    }

    override func setValue(_ value: Any, forTag tag: Int, client sender: Any) {
        NSLog("%@", "\(#function)(\(value), forTag: \(tag))")
        guard let value = value as? NSString else {
            return
        }
        guard let sender = sender as? IMKTextInput else {
            return
        }
        sender.overrideKeyboard(withKeyboardNamed: SettingStore().keyboardLayout())
    }
}

🖼入力メソッドの設定画面

入力メソッドはシステムに組込まれるので、ユーザとやりとりするための画面を持たない。 しかし、入力メソッドの挙動を設定するための設定画面は必要である。

macOS標準の日本語入力の設定画面はキーボード設定内に組み込んでいる。

f:id:mzp:20171118155509p:plain

この画面を実現するための非公開APIについて説明する。

f:id:mzp:20171118160629p:plain

🐙ソースコード/English version

🙊入力メソッドの設定画面

入力メソッドを選択した際、システム環境設定が入力メソッドに含まれる Resources/Preferences.prefPane を設定画面としてロードする。

Preferences.prefPane は、設定画面を提供するためのプラグイン形式であるPreference paneである。 Preference paneの詳細はPreference Pane Programming Guideに詳しい。

設定画面はシステム環境設定の一部としてロードされるので、直接入力メソッドとは別プロセスで動作する。 ユーザーが変更した内容は UserDefaults などを経由して入力メソッドと共有する。

f:id:mzp:20171118160140p:plain

(Preference Pane Programming Guide: Figure 1 Plug-in architecture of preference panesより引用)

設定画面の追加

XcodeのPreference Paneテンプレートを用いて設定画面用のターゲットを追加する。設定画面のファイル名はPreferences.prefPaneに固定されているので、ターゲット名はPreferences にする。

f:id:mzp:20171118160450p:plain  入力メソッドをビルドする際に設定画面もビルドされるように、入力メソッドのTarget Dependenciesに設定画面を追加する。

f:id:mzp:20171118160549p:plain  ビルド時に設定画面が入力メソッド内に埋め込まれるようCopy Fise Phaseを追加する。コピー先はResourcesディレクトリとする。

f:id:mzp:20171118160616p:plain

これでキーボード設定にて入力メソッドを選択した際に、設定画面がロードされるようになる。

🕊Swiftの利用

Preference Paneテンプレートで生成されたファイルはObjective-Cで記述されているが、Swiftで書き直したい。Swiftで書き直すために、実行時にlibswiftCore.dylibなどのSwiftの標準ライブラリをロードできるようにする必要がある。

macOSの実行ファイルは、rpathと呼ばれるディレクトリから動的リンクライブラリを探索する。 通常はこのrpathに@executable_path/../Frameworks が含まれている。 @executable_path は実行ファイルのパスに解決されるため、アプリケーション内のFrameworksディレクトリに依存する動的リンクライブラリを同梱する。

f:id:mzp:20171118161647p:plain

しかし設定ファイルはシステム環境設定に読み込まれるため、@executable_path/Applications/System Preferences.app/Contents/MacOS/System Preferences に解決される。そのため、Swiftの標準ライブラリが意図した通りにロードされない。

ロードされるファイルのパスは @loader_path で表現できる。設定画面のBuild Settingsを変更し@loader_path/../../../../Frameworks をRunpath Search Pathsに加える。  f:id:mzp:20171118162221p:plain

../../../../ を用いるのは以下のように入力メソッドとFrameworksを共有するためである。  f:id:mzp:20171118162239p:plain

🍫CocoaPodsの利用

CocoaPodsはPreference paneに対するフレームワークの埋め込みに対応していない。そのため、podを利用したい場合は、以下のようにホストとなるアプリケーションに埋め込む必要がある。

platform :osx, '10.11'
       
abstract_target 'App' do
  pod '※ikemen' <- 使える

  target 'EmojiIM'
  target 'Preferences' do
    pod 'NorthLayout'  # <- これは使えない
  end
end

🔑設定の共有

ユーザーが変更した設定は UserDefaults に保存するのが一般的である。 しかし入力メソッドはサンドボックス内で実行されるため、他のアプリケーションとUserDefaultsを共有できない。サンドボックスの詳細についてはApp Sandbox Design Guideが詳しい。

f:id:mzp:20171118163015p:plain

(App Sandbox Design Guideより引用)

設定画面がロードされるシステム環境設定はサンドボックス外で実行されているため、設定を共有するためにはサンドボックスを越える必要がある。 サンドボックスのEntitlementsで例外設定をすることで、サンドボックス外にアクセスできるようにする。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.temporary-exception.shared-preference.read-only</key>
        <string>jp.mzp.inputmethod.EmojiIM</string>
</dict>
</plist>

これを用いて以下のようなサンドボックスの内外で設定を共有できる UserDefaults のラッパーを作成する。

class SettingStore {
    // Sandbox内のアプリケーションのbundle identifier
    private let kSuiteName: String = "jp.mzp.inputmethod.EmojiIM"

    // システム環境設定にロードされているかどうかを判定する
    private var inSandbox: Bool {
        return Bundle.main.bundleIdentifier == kSuiteName
    }

    private lazy var userDefaults: UserDefaults? = {
        if inSandbox {
            // サンドボックス内ならUserDefaults.standardを使う
            return UserDefaults.standard
        } else {
            // サンドボックス外なら、名前を明示的に指定する
            return UserDefaults(suiteName: suiteName)
        }
    }()

    // userDefaultsを用いて設定を書き込む
    func setKeyboardLayout(inputSourceID: String) {
        userDefaults?.set(inputSourceID, forKey: "keyboardLayout")
        userDefaults?.synchronize()
    }

    // userDefaultsを用いて設定を読み込む
    func keyboardLayout() -> String {
        return (userDefaults?.value(forKey: "keyboardLayout") as? String) ?? "com.apple.keylayout.US"
    }
}

⌚️UTC生活

iPhone/AppleWatchのタイムゾーンUTCに切り替えて一ヶ月が経過した。

f:id:mzp:20171112200832p:plain

✨きっかけ

1ヶ月前に格闘していたpluginkitの時刻表示がUTCだった。

過去に、他のソフトウェアでもログがUTCで出力されたことなどを思い出したので、せっかくなので普段使うタイムゾーンUTCに切り替えた。

🔀 切り替えたもの

👍成果

  • 0:00、12:00などのキリがいい時間はすぐにJSTに変換できるようになった
  • キリがいい時間±2時間くらいもすぐ変換できるようになった

要はあまり成果がない。

🤔知見

  • 時報botとして@が使えると思いきや、イギリスはサマータイムを導入しているので使えない。
  • Twitter/Githubなどは相対時刻表示(3日前など)をしてるので、使い勝手は変わらない。
  • Yahoo!乗換案内はローカルタイムで検索するので、使えなくなる。Web版の乗換案内は大丈夫。
  • 寝起きに時計を見ても、まだ寝れるのか、起きたほうがいいのか判断がつかない。自分の感覚を信じる。
  • JSTで生活している人と待合せ時刻を決めるときは注意が必要。
  • 「今何時?」と聞かれたときに「UTCだと11時」とか答えてはいけない。
  • 困ったらSiriに「東京は何時?」と聞くとよい。

🙊入力モードの非公開機能

🌓入力モードは、Technical Noteやヘッダファイルに記載されていた内容である。 実際は、その内容以外にも文書化されていない機能がいくつか存在している。

📝文末文字の指定

macOSにはスペースを二回押すことでピリオドを入力する機能がある。(参考: macOS 10.12 SierraではiOSと同じスペースバーを2回押しでピリオド入力などの入力支援をサポート。 | AAPL Ch.)

f:id:mzp:20171029000928p:plain  ピリオドとして使用される文字を TISDoubleSpaceSubstitution でカスタマイズできる。入力モードごとに定義する。

f:id:mzp:20171029001004p:plain

<key>ComponentInputModeDict</key>
<dict>
  <key>tsInputModeListKey</key>
  <dict>
    <key>jp.mzp.inputmethod.EmojiIM</key>
    <dict>
      <key>TISDoubleSpaceSubstitution</key>
      <string>🍣</string>

👩‍👩‍👧‍👦 入力モードの統合

通常、入力モードは個別に追加・削除する。しかし、日本語入力ではひらがな入力とカタカナ入力、英数入力は基本的に併用するので、個別に追加・削除できる必要はない。

その場合、TISUnifiedUIForInputMethodEnabling を定義することで一括で追加削除できるようになる。

<key>ComponentInputModeDict</key>
<dict>
  <key>TISUnifiedUIForInputMethodEnabling</key>
  <true/>
  <key>tsInputModeListKey</key>
  <dict>
    <key>jp.mzp.inputmethod.EmojiIM</key>
    <dict>
    ....

この機能は、現在、日本語入力でのみ利用されている。

❓未解決

以下のキーの用途は、現時点で分かっていない。

  • TISCapsLockIsSwitch
  • TISCapsLockResetOnModeSwitch
  • TISIconIsTemplate
  • TISParticipatesInTouchBar

🐙ソースコード

Secret of input modes by mzp · Pull Request #16 · mzp/EmojiIM

🌓入力モード

一部の入力メソッドは複数のモードを持つ。例えば、macOS標準の日本語入力がひらがな入力モードとカタカナ入力モードを持つ。

EmojiIMを拡張し、絵文字入力モードとアルファベット入力モードの2つのモードを実装した。

f:id:mzp:20171026084007p:plain

📚関連資料

入力モードに関する資料は複数の箇所に分散している。 一箇所にまとめておいてほしい。

📝ソースコード

https://github.com/mzp/EmojiIM/pull/14

✨ 入力モードの定義

入力モードを定義するために、Info.plistに ComponentInputModeDict キーを追加する。 ComponentInputModeDict の値は辞書型であり、以下の2つのキーを持つ。

  • tsVisibleInputModeOrderedArrayKey: 入力モードを定義する
  • kTSVisibleInputModeOrderedArrayKey: 表示順を定義する。

tsVisibleInputModeOrderedArrayKey の辞書型であり、入力モードを識別するIDをキーとして持つため、おおまかに以下のような構造をとる。

<key>ComponentInputModeDict</key>
<dict>
  <key>tsInputModeListKey</key>
  <dict>
    <Key>入力モードID 1</key>
    <Dict><!-- 入力モードの定義 --></dict>                  
    <Key>入力モードID 2</key>
    <dict><!-- 入力モードの定義 --></dict>
  </dict>
  <key>tsVisibleInputModeOrderedArrayKey</key>
  <array>
    <String>入力モードID 1</string>
    <String>入力モードID 2</string>
  </array>
</dict>

入力モードのIDは既存の入力モードと同じ機能を提供する場合は同じキーを使い、独自の機能を提供する場合は独自のIDを使う。 既存の入力モードのIDはTextServices.hで定義されており、com.apple.inputmethod.Romancom.apple.inputmethod.Japanese.Hiragana などがある。

入力モードの定義も辞書型になっており、以下のキーを持つ。

キー名 必須・任意 意味
TISInputSourceID 任意 入力メソッドのbundle identifierからはじまる識別子。 入力モードのIDと同一である必要はない。|省略した場合は、bundle idと入力モードのIDから自動で決められる。
TISIntendedLanguage 必須 どの言語の入力モードか。
tsInputModePrimaryInScriptKey 必須 |使用する文字体系を指定する (例: 日本語ならsmJapanese)。どこで使われるかは不明。
tsInputModePrimaryInScriptKey 必須 この入力モードが、主となる文字体系か。どこで使われるかは不明。
tsInputModeMenuIconFileKey 必須 入力モードに対するアイコン
tsInputModeAlternateMenuIconFileKey 必須 メニューバーでクリックされたときのアイコン
tsInputModeDefaultStateKey 必須 設定画面で言語を選んだときに自動で選択されるか。(と書いてあるがよくわからない)
tsInputModeKeyEquivalentKey 任意 この入力モードに切り替えるために使うキー
tsInputModeKeyEquivalentModifiersKey 任意 この入力モードに切り替えるために使う修飾キー
tsInputModeJISKeyboardShortcutKey 任意 この入力モードに切り替えるために使うキー。(JISキーボード用)(0=none,1=hiragana,2=katakana,3=eisu)

これらをふまえてEmojiIMでは以下のように定義した。

<key>ComponentInputModeDict</key>
<dict>
         <key>tsInputModeListKey</key>
          <dict>
                 <key>jp.mzp.inputmethod.EmojiIM</key>
                  <dict>
                         <key>TISInputSourceID</key>
                         <string>jp.mzp.inputmethod.EmojiIM.emoji</string>
                         <key>TISIntendedLanguage</key>
                         <string>en</string>
                         <key>tsInputModeScriptKey</key>
                         <string>smUnicode</string>
                         <key>tsInputModeMenuIconFileKey</key>
                         <string>InputMethodIcon.tiff</string>
                         <key>tsInputModeAlternateMenuIconFileKey</key>
                         <string>InputMethodIcon.tiff</string>
                         <key>tsInputModeDefaultStateKey</key>
                         <true/>
                 </dict>
                 <key>com.apple.inputmethod.Roman</key>
                 <dict>
                          <key>TISInputSourceID</key>
                          <string>jp.mzp.inputmethod.EmojiIM.roman</string>
                          <key>TISIntendedLanguage</key>
                          <string>en</string>
                          <key>tsInputModeScriptKey</key>
                          <string>smRoman</string>
                          <key>tsInputModeMenuIconFileKey</key>
                          <string>InputMethodIcon.tiff</string>
                          <key>tsInputModeAlternateMenuIconFileKey</key>
                          <string>InputMethodIcon.tiff</string>
                          <key>tsInputModeDefaultStateKey</key>
                          <true/>
                  </dict>
          </dict>
          <key>tsVisibleInputModeOrderedArrayKey</key>
          <array>
                 <string>jp.mzp.inputmethod.EmojiIM</string>
                 <string>com.apple.inputmethod.Roman</string>
         </array>
</dict>

これで設定画面より入力モードの追加・削除ができる。

f:id:mzp:20171026083736p:plain

追加後はメニューバーから入力モードが切り替えられる。


f:id:mzp:20171026083804p:plain

🌐 入力モードの名前

設定画面に入力モードIDがそのまま表示されるのは分かりづらいので、分かりやすい名前をつける。

これはローカライゼーションの仕組みを用いて行なう。XcodeでInfoPlist.stringsを追加し以下のように定義する。

CFBundleName = "Emoji IM";

com.apple.inputmethod.Roman = "Emoji(Alphabet)";

jp.mzp.inputmethod.EmojiIM = "Emoji";

これで入力モードの名前を指定できる。

f:id:mzp:20171026082536p:plain

🔀入力コントローラでのモード切り替え

入力モードを切り替えた場合は、入力コントローラの setValue:forTag:client: が呼ばれる。

アルファベット入力モードかどうかを示すdirectMode変数の値を変更するようにした。

class EmojiInputController: IMKInputController {
    private var directMode: Bool = false
    override func setValue(_ value: Any, forTag tag: Int, client sender: Any) {
        // valueに入力モードIDが渡されるので、動作を切り替える
        guard let value = value as? NSString else {
            return
        }
        directMode = value == "com.apple.inputmethod.Roman"
    }
}

キー入力のハンドラでは、directMode変数に基づいて処理を決める。

class EmojiInputController: IMKInputController {
    override func handle(_ event: NSEvent, client sender: Any) -> Bool {
        NSLog("%@", "\(#function)((\(event), client: \(sender))")
        if directMode {
            // directModeが真ならfalseを返し、通常の処理を行なうようにする
            return false
        }

        // 通常の処理
    }
}

🌐各言語の入力メソッド

macOSに搭載されている各言語の入力メソッドの挙動を調べた。

🇨🇳中国語

発音を入力したのちに変換するピン音入力注音輸入法と、漢字の字形に基づいて入力したのち変換する五筆字型入力、およびその派生型がある。いずれの方式でも、入力した文字をもとに候補を表示するため、入力メソッドとしての挙動にはそれほど違いはない。 そのためか、同一の入力メソッドの異なるモードとして実装されている。

繁体字簡体字の2種類の字体体系があるが、入力メソッドの実装は大半が共通化されている。

f:id:mzp:20171022185227p:plain

入力方法とは直接の関係がないが、CapsLockを押した際の動作をカスタマイズできる。今回しらべた中では、中国語入力メソッドだけが持つ機能である。

f:id:mzp:20171023084224p:plain

🇯🇵日本語

入力方法についての説明は省略する。

ローマ字→かなとかな→漢字の2種類の変換に加えて、ライブ変換を持っているため、入力文字の遷移が複雑になっている。 そのためか、入力メソッドのバイナリのサイズも群を抜いて大きい。

f:id:mzp:20171022190311p:plain

他の言語の入力メソッドと異なり、ひらがな、カタカナといった入力モードを個別に追加・削除できない。

f:id:mzp:20171022190538p:plain

🇰🇷韓国語

ハングルは複数の字母の組み合わせで作られる。 その字母を順に入力していく。

「入力中テキスト」という状態を持たないため、カーソルの直前にある文字がキー操作によって変化する。

f:id:mzp:20171022190834p:plain

🇻🇳ベトナム語

CJKVと呼ばれるようにベトナム語には漢字(チュノム)が存在するが、日常的にはラテン文字による表記法 クオック・グーが使用される。 そのため、入力メソッドはアクセント記号の入力補助を行なう。

f:id:mzp:20171022191234p:plain

入力メソッドは自身が対応する文字体系をメタデータに持つが、ベトナム語入力メソッドはUnicodeに対応していると表記している。 理由はわからない。

🇮🇳ヒンディー語

ヒンディー語の表記に使われるデーヴァナーガリー文字には文字に対する記号の付与と子音結合が存在する。 そのため、カーソルの直前にある文字がキー操作によって変化する。

f:id:mzp:20171022194420p:plain

余談

📖変換候補の表示・選択・無

絵文字入力メソッドに絵文字候補の表示・選択を追加した。やったことと、IMKCandidatesクラスのドキュメントと挙動の差異について書く。

f:id:mzp:20171015111513g:plain

📦コード

#8 Show candidate using IMKCandidates

📝IMKCandidatesクラスの使い方

InputMethodKitのIMKCandidatesを用いると、変換候補ウインドウを表示できる。

変換候補ウインドウ表示

show() メソッドを用いると、変換候補ウインドウを表示できる。

let candidates: IMKCandidates = IMKCandidates(
  server: server,
  panelType: kIMKSingleColumnScrollingCandidatePanel,
  styleType: kIMKMain)
candidates.show()

ウインドウの形状は、初期化時に渡す panelType: 引数で指定する

kIMKSingleColumnScrollingCandidatePanel

縦一列。 

f:id:mzp:20171015102640p:plain

kIMKSingleRowSteppingCandidatePanel

横一列。

f:id:mzp:20171015102647p:plain

kIMKScrollingGridCandidatePanel

グリッド状の表示。

f:id:mzp:20171015102644p:plain

変換候補の取得

IMKCandidatesupdate() を呼ぶと、 IMKInputControllercandidates(:) 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
    }
    //別の処理
  }
  ...
}

変換候補の選択

候補選択が移動された場合や、選択が決定された場合はIMKInputControllercandidateSelectionChanged(:)candidateSelected(:) が呼ばれる。 そのため、これらのメソッドをオーバーライドすることで候補ウインドウの変化を受け取れる。

open override func candidateSelectionChanged(_ candidateString: NSAttributedString!) {
  // 候補選択が移動された
}

open override func candidateSelected(_ candidateString: NSAttributedString!) {
  // 候補選択が決定された
}

初期状態では候補選択が決定されたのちに、候補ウインドウが非表示になる。 この動作はsetDismissesAutomatically(_:)メソッドで変更できる。

✨絵文字入力メソッドの拡張

変換候補の表示・選択に対応するために、絵文字入力メソッドを拡張する。

  • 入力中状態の間は、絵文字の候補を表示する。
  • 入力中状態で←キなどの操作キーが押されたら、候補選択状態に遷移する。
  • 候補が選択されたら、候補選択状態から初期状態に遷移する。

状態遷移図は以下のようになる。変換候補の表示と関係ない箇所は破線にした。

f:id:mzp:20171015105154p:plain

操作キー、候補選択の追加

操作キーと候補選択を表現できるよう入力の種類を追加する。

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)に書いたが、これらのメソッドはなにもしない、もしくは定数を返すメソッドとして実装されている。 無である。