14

I've got an NSTextView subclass acting as its NSTextStorage delegate. I'm trying to do 2 things:

  1. Highlight the text in some ways
  2. Evaluate the text and then append the answer to the textview.

I'm doing this in two different methods, both invoked by the - (void)textStorageWillProcessEditing:(NSNotification *)notification delegate callback.

I can do the syntax highlighting just fine, but when it comes to appending my answer, the insertion point jumps to the end of the line and I don't really know why. My evaluate method looks like the following:

NSString *result = ..;
NSRange lineRange = [[textStorage string] lineRangeForRange:[self selectedRange]];
NSString *line = [[textStorage string] substringWithRange:lineRange];
line = [self appendResult:result toLine:line]; // appends the answer

[textStorage replaceCharactersInRange:lineRange withString:line];

Doing that will append my result just fine, but the problem is, as mentioned, the insertion point jumps to the end.

I've tried:

  1. Wrapping those above calls up in [textStorage beginEditing] and -endEditing.
  2. Saving the selection range (i.e., the insertion point) before changing the text storage so I can reset it afterwards, but no dice.

Am I doing this right? I'm trying to do this the least hackish way, and I'm also unsure if this is the ideal place to be doing my parsing/highlighting. The docs lead me to believe this, but maybe it's wrong.

jbrennan
  • 11,943
  • 14
  • 73
  • 115
  • What is lineForRange, I don't find that method in the docs? If you mean lineRangeForRange, then that could be your problem. Are you trying to append to the end of the line, or the end of your selection? – rdelmar Aug 28 '12 at 00:58
  • How about the second part of my question? It's not clear where you are trying to append your text. When you say the insertion point moves to the end of the line, do you mean after your insertion or before? – rdelmar Aug 28 '12 at 01:16
  • Sorry, Yeah I'm trying to append to the end of the line. – jbrennan Aug 28 '12 at 01:27
  • Once again you only answered half my question. What exactly is the problem? Is the text replacement happening correctly, but the insertion point moves to the end (and do you mean end of the text or just the line)? Or, is the replacement text in the wrong place? – rdelmar Aug 28 '12 at 04:04
  • From the question: `Doing that will append my result just fine, but the problem is, as mentioned, the insertion point jumps to the end.` – jbrennan Aug 28 '12 at 12:41

3 Answers3

6

Reason for the insertion point to move

Suprisingly, I never found an actual explanation to why these suggestion do (or do not) work.

Digging into it, the reason for the insertion point to move is: .editedCharacters (NSTextStorageEditedCharacters in ObjC)affects the position of the insertion point from NSLayoutManager.processEditing(from:editedMask:...).

If only .editedAttributes/NSTextStorageEditedAttributes is sent, the insertion point will not be touched. This is what you will want to achieve when you highlight: change attributes only.

Why highlighting affects the insertion point

The problem with highlighting here is that NSTextStorage collects all edited calls during a single processing run and combines the ranges, starting with the user-edited change (e.g. the insertion when typing), then forming a union of this and all ranges reported by addAttributes(_:range:). This results in one single NSLayoutManager.processEditing(from:editedMask:...) call -- with an editedMask of both [.editedCharacters, .editedAttributes].

So you want to send .editedAttributes for the highlighted ranges but end up forming a union with .editedCharacters instead. That union moves the insertion point waaaaaaaay beyond where it should go.

Changing the order in processEditing to call super first works because the layout manager will be notified of a finished edit. But this approach will still break for some edge cases, resulting in invalid layout or jiggling scroll views while you type in very large paragraphs.

This is true for hooking into NSTextStorageDelegate, too, by the way.

Hook into callbacks after layout has truly finished to trigger highlighting instead of processEditing

The only solution that will work robustly based on reasons inherent to the Cocoa framework is to perform highlighting from textDidChange(_:) exclusively, i.e. after the layout processing really has been finished. Subscribing to NSTextDidChangeNotification work just as well.

Downside: you have to trigger highlighting passes for programmatic changes to the underlying string as these will not invoke the textDidChange(_:) callback.


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 post is still a self-contained solution in itself: http://christiantietze.de/posts/2017/11/syntax-highlight-nstextstorage-insertion-point-change/

ctietze
  • 2,805
  • 25
  • 46
  • 1
    If you are resetting attributes of the entire text view, this is definitely the best approach. Doing it after `super.processEditing()` will break emoji and doing it before will break your selection. Doing in text view's delegate is exactly what you want. – Sam Soffes Mar 09 '19 at 17:07
4

I know that this question has been long since answered, however I had exactly the same issue. In my NSTextStorage subclass I was doing the following:

- (void)processEditing {
    //Process self.editedRange and apply styles first
    [super processEditing];
}

However, the correct thing to do is this:

- (void)processEditing {
    [super processEditing];
    //Process self.editedRange and apply styles after calling superclass method
}
Thomas Denney
  • 1,578
  • 2
  • 15
  • 25
  • 1
    Thanks, that solved my problem. If you use the NSTextStorageDelegate and you experience this problem, use textStorageDidProcessEditing: instead of textStorageWillProcessEditing:. Do read the documentation warnings about the leaving the textStorage in an inconsistent state, though. – Elise van Looij May 13 '14 at 09:40
  • 1
    Heads up: While this does work, you have to be careful not to mess too much with the `NSLayoutManager` in the process, because the call to the parent's `processEditing` fixes attributes and marks the end of a begin/endEditing block, so you're setting highlight attributes outside of regular editing enclosings. – ctietze Nov 23 '17 at 17:39
  • I've discovered this solution as well. Do you know why you must call super first instead of last? This seems to work, but I don't know why. – sam Sep 23 '18 at 18:22
0

It's simple! I ended up breaking this problem into 2 parts. I still do my syntax highlighting as a result of the textStorage delegate callback, but now I do my evaluation and appending elsewhere.

I ended up overriding both -insertText: and -deleteBackwards: (I might also want to do the same for -deleteForwards:, too). Both overrides look like the following:

- (void)insertText:(id)insertString {
    [super insertText:insertString];
    NSRange selectedRange = [self selectedRange];
    [self doEvaluationAndAppendResult];
    [self setSelectedRange:selectedRange];
}

I ended up having to reset the insertion point manually here. I'd still love to figure out why that's necessary, but at least this feels like less of a hack.

jbrennan
  • 11,943
  • 14
  • 73
  • 115
  • 1
    This post-fix, moving the insertion point, will only work when the affected range did not cause scrolling -- or the user will see the scroll bar flicker shortly and the line of the insertion point scrolled up towards the middle of the text view. That happens when the `doEvaluationAndAppendResult` stuff scrolls up so that `setSelectedRange:` is set to a range outside the visible rect. – ctietze Dec 11 '17 at 15:16