NSTextStorageDelegate's textStorage(_,willProcessEditing:,range:,changeInLength:) moves selection
Asked Answered
C

2

5

I'm trying to implement a syntax-coloring text editor that also does things like insert whitespace at the start of a new line for you, or replace text with text attachments.

After perusing the docs again after a previous implementation had issues with undoing, it seems like the recommended bottleneck for this is NSTextStorageDelegate's textStorage(_,willProcessEditing:,range:,changeInLength:) method (which states that Delegates can change the characters or attributes., whereas didProcessEditing says I can only change attributes). This works fine, except that whenever I actually change attributes or text, the text insertion mark moves to the end of whatever range of text I modify (so if I change the style of the entire line, the cursor goes at the end of the line).

Does anybody know what additional call(s) I am missing that tell NSTextStorage/NSTextView not to screw up the insertion mark? Also, once I insert text, I might have to tell it to move the insertion mark to account for text I've inserted.

Note: I've seen Modifying NSTextStorage causes insertion point to move to the end of the line, but that assumes I'm subclassing NSTextStorage, so I can't use the solution there (and would rather not subclass NSTextStorage, as it's a semi-abstract subclass and I'd lose certain behaviours of Apple's class if I subclassed it).

Concierge answered 16/7, 2017 at 9:15 Comment(15)
Did you see the last comment in the linked question?Pneumato
You mean the "use didProcess instead of willProcess?" Yes, but that 1. refers to the old, deprecated willProcess:/didProcess: notification methods and 2. would be against Apple's docs, which clearly state that I may only change the text from willProcess.Concierge
If you mean "do read the documentation warnings about leaving the textStorage in an inconsistent state", I can't find those :-(Concierge
You could have mentioned this in the question.Pneumato
I tried this and the insertion point doesn't move. How do you change the text?Pneumato
Right now I only call NSTextStorage addAttribute on the range for the line where the edit occurred. (Not just on the edited range)Concierge
The layout manager "fixes" the selection. Maybe you can fix the selection again in textDidChange(_:) of NSTextDelegate.Pneumato
Just tried that. Apparently the fixing happens after textDidChange(_, didProcessEditing:,range:,changeInLength:).Concierge
The fixing happens before textDidChange(_:).Pneumato
I was referring to the selected range being changed by NSTextStorage. I saved it at the start of will, restored at the end of did, no dice. textDidChange(_:) is deprecated. What call are you referring to, if that doesn't work?Concierge
I'm referring to textDidChange(_:) of NSTextDelegate, the superprotocol of NSTextViewDelegate. textDidChange(_:) of NSTableView is deprecated.Pneumato
How do I find out what changed from that call, though?Concierge
Ah, I guess I can combine the two messages, save away the editedRange etc. and then access it from textDidChange.Concierge
Yes, calculate the new insertion point in textStorage(_,willProcessEditing:,range:,changeInLength:) and set it in textDidChange(_:).Pneumato
@Concierge can you share your solution as an answer?Legra
L
4

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

What happens when you type and change style

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:

  1. 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;
  2. 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/

Legra answered 29/11, 2017 at 20:25 Comment(0)
I
0

The above accepted answer with the notification center worked for me, but I had to include one more thing when editing text. (Which may be different from selection).

The editedRange of the NSTextStorage was whack after the notification center callback. So I keep track of the last known value myself by overriding the processEditing function and using that value later when I get the callback.

override func processEditing() {
        // Hack.. the editedRange property when reading from the notification center callback is weird
        lastEditedRange = editedRange
        super.processEditing()
    }
Incoordinate answered 18/2, 2022 at 19:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.