みずぴー日記

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

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

絵文字入力メソッドに絵文字候補の表示・選択を追加した。やったことと、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)に書いたが、これらのメソッドはなにもしない、もしくは定数を返すメソッドとして実装されている。 無である。