Search code examples
swiftmacosappkitnstextview

NSTextView cursor doesn't appear when typing text on macOS 10.14


I'm observing a strange issue on macOS 10.12 Mojave with NSTextView.

The cursor is not appearing when I delete or insert text.

I'm changing the textStorage attributes in didChangeText() like this :

self.textStorage?.beginEditing()

ARTokenManager.getToken(text: text, language: language) { (tokens) in
    // This line reset the attributes
    // If I remove it, the cursor appear properly
    // But the attributes are conserved 
    self.textStorage?.setAttributes([NSAttributedString.Key.font: self.font!,
                                     NSAttributedString.Key.foregroundColor: self.defaultTextColor], range: range)
    for token in tokens {
        let attributeRange = NSRange(location: token.range.location + range.location, length: token.range.length)
        self.textStorage?.addAttributes(token.attributes, range: attributeRange)
    }
}

self.textStorage?.endEditing()

When I remove the setAttributes method, everything works as expected, but I can't explain why. I'm possibly resetting the attributes wrong. This issue only works with Mojave.

Does someone have the same issue or can explain me what I'm doing wrong ?

Thank you.


Solution

  • After some research, I discovered that my question was more about syntax highlighting with NSTextView. I know this is a question that a lot of macOS developers are asking about and there are a lot of solutions for that. This is not probably the best one, but this is how I’ve solved this problem.

    NSTextStorage

    To achieve that, I’ve used a subclass of NSTextStorage. This is where all the syntax work will be done. NSTextStorage is not protocol oriented so you have to override method by yourself as the Apple documentation suggest :

    class SyntaxTextStorage: NSTextStorage {
    
        private var storage: NSTextStorage
    
        override var string: String {
            return storage.string
        }
    
        override init() {
            self.storage = NSTextStorage(string: "")
            super.init()
        }
    
        override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] {
            return storage.attributes(at: location, effectiveRange: range)
        }
    
        override func replaceCharacters(in range: NSRange, with str: String) {
            beginEditing()
            storage.replaceCharacters(in: range, with: str)
            edited(.editedCharacters, range: range, changeInLength: str.count - range.length)
            endEditing()
        }
    
        override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
            beginEditing()
            storage.setAttributes(attrs, range: range)
            edited(.editedAttributes, range: range, changeInLength: 0)
            endEditing()
        }
    
    }
    

    This is the basic to create your text storage.

    NSTextStorage + NSTextView

    The next step is to set your text storage into your textView. To do so, you can use the replaceTextStorage() method accessible in the textView layoutManager.

    class SyntaxTextView: NSTextView {
    
        var storage: SyntaxTextStorage!
    
        override func awakeFromNib() {
            super.awakeFromNib()
            configureTextStorage()
        }
    
        private func configureTextStorage() {
            storage = SyntaxTextStorage()
            layoutManager?.replaceTextStorage(storage)
        }
    
    }
    

    Syntaxing

    The final step is to do your syntax job. The CPU cost of this process is very hight. There is a lot of way to do it to have the best performances. I suggest you to implement a class that will returns you a list of NSAttributedString and NSRange. The job of the text storage should only be applying the style to your text. Personally, I've used the processEditing method to perform my text analyze :

    override func processEditing() {
        super.processEditing()
        syntaxCurrentParagraph()
    }
    

    I recommend you to do you syntax analyze in background, then, if there is no text change since your last analyze, apply the change to your text. Always in my text storage, I've implemented a syntax method that apply the style to the text :

    private func syntax(range: NSRange, completion: ((_ succeed: Bool) -> Void)? = nil) {
        guard range.length > 0 else {
            completion?(true)
            return
        }
    
        // Save your data to do the job in background
        let currentString = self.string
        let substring = currentString.substring(range: range)
        let mutableAttributedString = NSMutableAttributedString(string: substring, attributes: NSAttributedString.defaultAttributes as [NSAttributedString.Key : Any])
    
        DispatchQueue.global(qos: .background).async {
            ARSyntaxManager.tokens(text: substring, language: self.language) { (tokens) in
                // Fill you attributed string
                for token in tokens {
                    mutableAttributedString.addAttributes(token.attributes, range: token.range)
                }
    
                DispatchQueue.main.async {
                    // Check if there is no change
                    guard self.string.count == currentString.count else {
                        completion?(false)
                        return
                    }
    
                    completion?(true)
    
                    // Apply your change
                    self.storage.replaceCharacters(in: range, with: mutableAttributedString)
                    self.displayVisibleRect()
                }
            }
        }
    }
    

    That's it. Hope it will help some of you.