Swift：NSTextViewで文字列の補完候補を出す

# はじめに

例えば，NSTextViewで文字列の入力を受け付けている際，タグ文字列を検出してタグの候補を出したい時，ユーザ名を検出してユーザの候補を出したい時などがあると思います．NSTextViewで文字列の補完候補を出すための最小プログラムをまとめておきます．

# デモ

今回は，Scrollable Text Viewを対象として， `#`付きのタグ文字列の入力中にタグの候補を出す例を示します．
![complement.gif](https://s3-ap-northeast-1.amazonaws.com/kyome.io/resources/complement.gif)

# ソース

下記の`ComplementTextView`を対象のTextViewのクラスに指定してあげればそれだけで動きます．（今回はViewControllerいらず）

```swift:ComplementTextView.swift
import Cocoa

// タグの候補（簡単のため固定のものを配列で用意）
let candidates: [String] = [
    "App",
    "apple",
    "ban",
    "Banana",
    "Qiita",
    "quality",
    "question",
    "Quit"
]

typealias Tag = (text: String, range: NSRange)

class ComplementTextView: NSTextView {

    // 現在補完候補のテーブルが出ているかどうか判断するフラグ
    private var isComplementing: Bool = false
    private var complementRange = NSRange()
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.font = NSFont.systemFont(ofSize: 20)
    }
    
    override func didChangeText() {
        setAttributes()
    }
    
    // 必須ではないが，分かりやすくタグ文字列の色を変えるために追加してみた
    private func setAttributes() {
        let caretPosition = selectedRanges.first?.rangeValue.location
        var attributes: [NSAttributedString.Key : Any] = [
            .foregroundColor : NSColor.textColor,
            .font : font!
        ]
        let len = (self.string as NSString).length
        textStorage?.setAttributes(attributes, range: NSRange(location: 0, length: len))
        let tags = extractTags()
        attributes[.foregroundColor] = NSColor.blue
        tags.forEach { (tag) in
            attributes[.link] = tag.text
            textStorage?.setAttributes(attributes, range: tag.range)
        }
        if caretPosition != nil {
            setSelectedRange(NSRange(location: caretPosition!, length: 0))
        }
    }
    
    // ここから下が重要
    private func extractTags() -> [Tag] {
        let text = self.string
        let pattern: String = ##"(\A|\s)(#[^#\s]+)"##
        guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count))
        return matches.map { (result) -> Tag in
            let text = (text as NSString).substring(with: result.range(at: 2))
            return (text, result.range(at: 2))
        }
    }
    
    override func keyUp(with event: NSEvent) {
        super.keyUp(with: event)
        let caretPosition = selectedRanges.first?.rangeValue.location
        let tags = extractTags()
        tags.forEach { (tag) in
            if tag.range.upperBound == caretPosition {
                // #の分をずらす
                complementRange = NSRange(location: tag.range.location + 1,
                                          length: tag.range.length - 1)
            }
        }
        if complementRange.length > 0 && !isComplementing {
            isComplementing = true
            complete(self)
        }
    }
    
    // 置換する領域
    override var rangeForUserCompletion: NSRange {
        return complementRange
    }
    
    // 補完候補を指定するやつ
    override func completions(forPartialWordRange charRange: NSRange, indexOfSelectedItem index: UnsafeMutablePointer<Int>) -> [String]? {
        guard let range = Range(charRange, in: self.string) else { return nil }
        let candidates = getCandidates(String(self.string[range]))
        if candidates.isEmpty {
            isComplementing = false
            complementRange = NSRange()
        }
        return candidates
    }
    
    // 補完候補一覧テーブルに対して操作を行った時に呼ばれるもの
    // movement から操作が決定操作だったのか，矢印キーによる選択操作だったのか判別可能
    override func insertCompletion(_ word: String, forPartialWordRange charRange: NSRange, movement: Int, isFinal flag: Bool) {
        if flag {
            isComplementing = false
            complementRange = NSRange()
            guard let tm = NSTextMovement(rawValue: movement) else { return }
            switch tm {
            case .return, .tab: // returnキーまたはtabキーが押された時
                insertText("\(word) ", replacementRange: charRange)
            case .left: // ←キーが押された時
                let newLocation: Int = max(0, charRange.upperBound - 1)
                setSelectedRange(NSRange(location: newLocation, length: 0))
            case .right: // →キーが押された時
                let maximum: Int = (self.string as NSString).length
                let newLocation: Int = min(charRange.upperBound + 1, maximum)
                setSelectedRange(NSRange(location: newLocation, length: 0))
            default: break
            }
        }
    }
    
    private func getCandidates(_ keyword: String) -> [String] {
        return candidates.filter({ (str) -> Bool in
            return str.lowercased().hasPrefix(keyword.lowercased())
        })
    }
    
}
```

# 所感

NSTextFieldに関する文献はそこそこ出てくるのですが，NSTextViewになると途端に文献が皆無になるのはどういうことでしょうか...とても辛い...