みずぴー日記

人間の再起動ボタンはハワイのビーチにある

⚡️ReactiveInputMethod

🍣入力メソッドを拡張し、テキストを入力し、Enterで確定できるようにした。

f:id:mzp:20171001221958g:plain

コード

https://github.com/mzp/EmojiIM/tree/marked

未確定文字列の挿入

未確定文字列は、入力セッションの一部としてマークされている文字列なのでmarked textと呼ばれる。

IMKTextInput プロトコルsetMarkedText で設定できる。例えば"あいうえ"を未確定文字列として表示したい場合は、以下のコードになる。

let notFound = NSRange(location: NSNotFound, length: NSNotFound)
client.setMarkedText("あいうえ", selectionRange: notFound, replacementRange: notFound)

クリアしたい場合は第一引数に空文字列を渡す。nilを渡すとクラッシュする。

状態遷移

未確定文字列の入力した後、入力を確定するためには、入力メソッド内で状態遷移を管理する必要がある。

未確定文字列を持たない初期状態と、未確定文字列を持つ入力中状態があり、キー入力とEnterキーの入力で遷移するので 状態遷移図は以下のようになる。

f:id:mzp:20171001222314p:plain

ReactiveAutomaton

状態遷移を記述するためにReactiveAutomatonを用いる。

これはReactiveCocoaで状態遷移機械を書くためのライブラリである。アイデアは以下のスライドで解説されいる。

このライブラリを使い、入力メソッドの状態遷移および遷移時のアクションを定義する。

入力

ユーザの入力はテキスト入力とEnterの押下の2種類とする。 以下のenumで定義する。テキスト入力は入力された文字列をパラメータに持つようにする。

public enum UserInput {
  case input(text: String)
  case enter
}

状態

入力メソッドの状態は「通常状態」と「入力中状態」の2種類とする。以下のenumで定義する。

public enum InputMethodState {
  case normal
  case composing
}

遷移

ReactiveAutomatonのDSLを用いて状態遷移を定義する。

static func isInput(_ state: UserInput) -> Bool {
  switch state {
  case .input:
    return true
  default:
    return false
  }
}

let mappings: [ActionMapping<InputMethodState, UserInput>] = [
  /*  Input <|> fromState => toState */
  /* --------------------------------*/
  isInput <|> .normal => .composing,
  isInput <|> .composing => .composing,
  .enter  <|> .composing => .normal
]

遷移が発生した際に実行するアクションを指定できるようにDSLを拡張する。 (ReactiveAutomaton+Action.swift) そして、遷移時のアクションで確定文字列、未確定文字列を更新する。

let (text, textObserver) = Signal<String, NoError>.pipe()
let markedTextProperty = MutableProperty<String>("")

let mappings: [ActionMapping<InputMethodState, UserInput>] = [
  /*  Input <|> fromState => toState <|> action */
  /* -------------------------------------------*/
  isInput <|> .normal => .composing <|> {
    switch $0 {
    case .input(text: let text):
      markedTextProperty.swap(text)
    default:
      ()
    }
  },
  isInput <|> .composing => .composing <|> {
    switch $0 {
    case .input(text: let text):
      markedTextProperty.modify { $0.append(text) }
    default:
      ()
    }
  },
  .enter <|> .composing => .normal <|> { _ in
    textObserver.send(value: markedTextProperty.value)
    markedTextProperty.swap("")
  }
]

この状態遷移をもとに、オートマトンを作る。

let (inputSignal, observer) = Signal<UserInput, NoError>.pipe()
self.automaton = Automaton(state: .normal, input: inputSignal, mapping: reduce(mappings))

入力コントローラとの接続

イベントの処理

オートマトンと入力コントローラを接続するために、オートマトンにイベントを送るメソッドを作る。 入力コントローラの仕様にあわせ、状態遷移が発生した場合は真を、そうでない場合は偽を返すようにする。

init() {
  ...
  automaton.replies.observeValues {
    switch $0 {
    case .success:
      self.handled = true
    default:
      ()
    }
  }
}

func handle(_ input: UserInput) -> Bool {
  handled = false
  observer.send(value: input)
  return handled
}

入力コントローラで、キー入力に応じて、このメソッドを呼びだす。

public override func handle(_ event: NSEvent!, client sender: Any!) -> Bool {
  if event.keyCode == 36 {
    return automaton.handle(.enter)
  } else if event.keyCode == 51 {
    return automaton.handle(.backspace)
  } else if let text = event.characters {
    return automaton.handle(.input(text: text))
  } else {
    return false
  }
}

未確定文字列・確定文字列の反映

オートマトンが持つ入力文字列、未確定文字列を監視し、更新があった際にクライアントアプリケーションに反映するようにする。これはReaciveSwiftのイベント監視の仕組みを用いる。

public override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) {
  super.init(server: server, delegate: delegate, client: inputClient)

  guard let client = inputClient as? IMKTextInput else {
    return
  }
  // 未確定文字列の反映
  automaton.markedText.signal.observeValues { text in
    let notFound = NSRange(location: NSNotFound, length: NSNotFound)
    client.setMarkedText(text, selectionRange: notFound, replacementRange: notFound)
  }
  // 確定文字列の挿入
  automaton.text.observeValues {
    let notFound = NSRange(location: NSNotFound, length: NSNotFound)
    client.insertText($0, replacementRange: notFound)
  }
}