I found out the source of the problem.
And the only solution that will work robustly based on reasons inherent to the Cocoa framework instead of mere work-arounds. (Note there's probably at least one other, metastable approach based on a ton of quick-fixes that produces a similar result, but as metastable alternatives go, that'll be very fragile and require a ton of effort to maintain.)
TL;DR Problem: NSTextStorage
collects edited
calls and combines the ranges, starting with the user-edited change (e.g. the insertion), then adding all ranges from addAttributes(_:range:)
calls during highlighting.
TL;DR Solution: Perform highlighting from textDidChange(_:)
exclusively.
Details
This only applies to a single processEditing()
run, both in NSTextStorage
subclasses and in NSTextStorageDelegate
callbacks.
The only safe way to perform highlighting I found is to hook into NSText.didChangeNotification
or implement NSTextDelegate.textDidChange(_:)
.
As per @Willeke's comments to the OP's question, this is the best place to perform changes after the layout pass. But as opposed to the comment thread, setting back NSText.selectedRange
does not suffice. You won't notice the problem of post-fixing the selection after the caret has moved away until
- you highlight whole blocks of text,
- spanning multiple lines, and
- exceeding the visible (
NSClipView
) boundaries of the scroll view.
In this rare case, most keystrokes will make the scroll view jiggle or bounce around. But there's no additional quick-fix against this. I tried. Neither preventing sending the scroll commands from private API in NSLayoutManager
nor avoiding scrolling by overriding all methods with "scroll" in them from a NSTextView
subclass works well. You can stop scrolling to the insertion point altogether, sure, but no such luck getting a solid algorithm out that does not scroll only when you perform highlighting.
The didChangeNotification
approach does work reliably in all situations I and my app's testers were able to come up with (including a crash situation as weird as scrolling the text and then, during the animation, replacing the string with something shorter -- yeah, try to figure that kind of stuff out from crash logs that report invalid glyph generation ...).
This approach works because it does 2 glyph generation passes:
- One pass for the edited range, in the case of typing for every key stroke with a
NSRange
of length 1, sending the edited
notification with both [.editedCharacters, .editedAttributes]
, the former being responsible for moving the caret;
- another pass for whatever range is affected by syntax highlighting, sending the
edited
notification with [.editedAttributes]
only, thus not affecting the caret's position at all.
Even more details
In case you want to know more about the source of the problem, I put more my research, different approaches, and details of the solution in a much longer blog post for reference. This here, though, is the solution itself. http://christiantietze.de/posts/2017/11/syntax-highlight-nstextstorage-insertion-point-change/
textDidChange(_:)
ofNSTextDelegate
. – PneumatotextDidChange(_:)
. – PneumatoNSTextDelegate
, the superprotocol ofNSTextViewDelegate
.textDidChange(_:)
ofNSTableView
is deprecated. – PneumatotextStorage(_,willProcessEditing:,range:,changeInLength:)
and set it intextDidChange(_:)
. – Pneumato