swiftnsattributedstringnstextviewnstextstorage

Replacing NSAttributedString in NSTextStorage Moves NSTextView Cursor


I followed this tutorial and created a Mac version of it. It works pretty well, except there's a bug I can't figure out. The cursor jumps to the end of the string if you try editing anything in the middle of the string. Like this:

NSTextView Cursor Jumps to End

Here is a sample project, or you can just create a new macOS project and put this in the default ViewController.swift:

import Cocoa

class ViewController: NSViewController, NSTextViewDelegate {
  var textView: NSTextView!
  var textStorage: FancyTextStorage!

  override func viewDidLoad() {
    super.viewDidLoad()

    createTextView()
  }

  func createTextView() {
    // 1
    let attrs = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 13)]
    let attrString = NSAttributedString(string: "This is a *cool* sample.", attributes: attrs)
    textStorage = FancyTextStorage()
    textStorage.append(attrString)

    let newTextViewRect = view.bounds

    // 2
    let layoutManager = NSLayoutManager()

    // 3
    let containerSize = CGSize(width: newTextViewRect.width, height: .greatestFiniteMagnitude)
    let container = NSTextContainer(size: containerSize)
    container.widthTracksTextView = true
    layoutManager.addTextContainer(container)
    textStorage.addLayoutManager(layoutManager)

    // 4
    textView = NSTextView(frame: newTextViewRect, textContainer: container)
    textView.delegate = self
    view.addSubview(textView)

    // 5
    textView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
      textView.topAnchor.constraint(equalTo: view.topAnchor),
      textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ])
  }

}

And then create a FancyTextStorage class that subclasses NSTextStorage with this:

class FancyTextStorage: NSTextStorage{
  let backingStore = NSMutableAttributedString()
  private var replacements: [String: [NSAttributedString.Key: Any]] = [:]

  override var string: String {
    return backingStore.string
  }
  override init() {
    super.init()
    createHighlightPatterns()
  }

  func createHighlightPatterns() {
    let boldAttributes = [NSAttributedString.Key.font: NSFont.boldSystemFont(ofSize: 13)]
    replacements = ["(\\*\\w+(\\s\\w+)*\\*)": boldAttributes]
  }

  func applyStylesToRange(searchRange: NSRange) {
    let normalAttrs = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 13, weight: .regular), NSAttributedString.Key.foregroundColor: NSColor.init(calibratedRed: 0.5, green: 0.5, blue: 0.5, alpha: 1.0)]

    addAttributes(normalAttrs, range: searchRange)

    // iterate over each replacement
    for (pattern, attributes) in replacements {
      do {
        let regex = try NSRegularExpression(pattern: pattern)
        regex.enumerateMatches(in: backingStore.string, range: searchRange) {
          match, flags, stop in
          // apply the style
          if let matchRange = match?.range(at: 1) {
            print("Matched pattern: \(pattern)")
            addAttributes(attributes, range: matchRange)

            // reset the style to the original
            let maxRange = matchRange.location + matchRange.length
            if maxRange + 1 < length {
              addAttributes(normalAttrs, range: NSMakeRange(maxRange, 1))
            }
          }
        }
      }
      catch {
        print("An error occurred attempting to locate pattern: " +
              "\(error.localizedDescription)")
      }
    }
  }

  func performReplacementsForRange(changedRange: NSRange) {
    var extendedRange = NSUnionRange(changedRange, NSString(string: backingStore.string).lineRange(for: NSMakeRange(changedRange.location, 0)))
    extendedRange = NSUnionRange(changedRange, NSString(string: backingStore.string).lineRange(for: NSMakeRange(NSMaxRange(changedRange), 0)))
    beginEditing()
    applyStylesToRange(searchRange: extendedRange)
    endEditing()
  }

  override func processEditing() {
    performReplacementsForRange(changedRange: editedRange)
    super.processEditing()
  }

  override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] {
    return backingStore.attributes(at: location, effectiveRange: range)
  }

  override func replaceCharacters(in range: NSRange, with str: String) {
    print("replaceCharactersInRange:\(range) withString:\(str)")

    backingStore.replaceCharacters(in: range, with:str)
    edited(.editedCharacters, range: range,
          changeInLength: (str as NSString).length - range.length)
  }

  override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
    //print("setAttributes:\(String(describing: attrs)) range:\(range)")
    backingStore.setAttributes(attrs, range: range)
    edited(.editedAttributes, range: range, changeInLength: 0)
  }

  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
  }

  required init?(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) {
    fatalError("init(pasteboardPropertyList:ofType:) has not been implemented")
  }
}

It seems that when the string is rewritten, it doesn't preserve the cursor position, but this same code on iOS (from the above-mentioned tutorial) doesn't have this problem.

Any ideas?


Solution

  • I think I (hopefully) figured it out after reading this article: https://christiantietze.de/posts/2017/11/syntax-highlight-nstextstorage-insertion-point-change/

    Within my ViewController.swift, I added the textDidChange delegate method and reusable function for updating the styles:

    func textDidChange(_ notification: Notification) {
      updateStyles()
    }
    
    func updateStyles(){
      guard let fancyTextStorage = textView.textStorage as? FancyTextStorage else { return }
    
      fancyTextStorage.beginEditing()
      fancyTextStorage.applyStylesToRange(searchRange: fancyTextStorage.extendedRange)
      fancyTextStorage.endEditing()
    }
    

    Then within the FancyTextStorage, I have to remove performReplacementsForRange from processEditing() because it calls applyStylesToRange() and the point of the aforementioned article is that you can't apply styles within TextStorage's processEditing() function or else the world will explode (and the cursor will move to the end).

    I hope this helps someone else!