I am writing a special-purpose text editor in cocoa that does things like automatic text substitution, inline text completions (ala Xcode), etc.
I need to be able to programmatically manipulate the NSTextView
’s NSTextStorage
in response to 1) user typing, 2) user pasting, 3) user dropping text.
I have tried two different general approaches and both of them have caused the NSTextView
’s native undo manager to get out of sync in different ways. In each case, I am only using NSTextView
delegate methods. I have been trying to avoid subclassing NSTextview
or NSTextStorage
(though I will subclass if necessary).
The first approach I tried was doing the manipulations from within the textView
delegate
’s textDidChange
method. From within that method, I analyzed what had been changed in the textView
and then called a general purpose method for modifying text that wrapped the changes in the textStorage with calls to shouldChangeTextInRange:
and didChangeText:
. Some of the programmatic changes allowed clean undo’s but some did not.
The second (and maybe more intuitive because it makes changes before the text actually appears in the textView
) approach I tried was doing the manipulations from within the delegate
’s shouldChangeTextInRange:
method, again using the same general purpose storage modification method that wraps changes in the storage with a call to shouldChangeTextInRange:
and didChangeText:
. Since these changes were being triggered originally from within shouldChangeTextInRange:
, I set a flag that told the inner call to shouldChangeTextInRange:
to be ignored so as not to enter recursive blackholeness. Again, Some of the programmatic changes allowed clean undo’s but some did not (though different ones this time, and in different ways).
With all that background, my question is, can someone point me to a general strategy for programmatically manipulating the storage of an NSTextview
that will keep the undo manager clean and in sync?
In which NSTextview
delegate method should I pay attention to the text changes in the textView (via typing, pasting, or dropping) and do the manipulations to the NSTextStorage
? Or is the only clean way to do this by subclassing either NSTextView
or NSTextStorage
?
I originally posted a similar question fairly recently (thanks to OP for pointing from there back to this question).
That question was never really answered to my satisfaction, but I do have a solution to my original problem which I believe also applies to this.
My solution is not use to the delegate methods, but rather to override NSTextView
. All of the modifications are done by overriding insertText:
and replaceCharactersInRange:withString:
My insertText:
override inspects the text to be inserted, and decides whether to insert that unmodified, or do other changes before inserting it. In any case super's insertText:
is called to do the actual insertion. Additionally, my insertText:
does it's own undo grouping, basically by calling beginUndoGrouping:
before inserting text, and endUndoGrouping:
after. This sounds way too simple to work, but it appears to work great for me. The result is that you get one undo operation per character inserted (which is how many "real" text editors work - see TextMate, for example). Additionally, this makes the additional programmatic modifications atomic with the operation that triggers them. For example, if the user types {, and my insertText:
programmatically inserts }, both are included in the same undo grouping, so one undo undoes both. My insertText:
looks like this:
- (void) insertText:(id)insertString
{
if( insertingText ) {
[super insertText:insertString];
return;
}
// We setup undo for basically every character, except for stuff we insert.
// So, start grouping.
[[self undoManager] beginUndoGrouping];
insertingText = YES;
BOOL insertedText = NO;
NSRange selection = [self selectedRange];
if( selection.length > 0 ) {
insertedText = [self didHandleInsertOfString:insertString withSelection:selection];
}
else {
insertedText = [self didHandleInsertOfString:insertString];
}
if( !insertedText ) {
[super insertText:insertString];
}
insertingText = NO;
// End undo grouping.
[[self undoManager] endUndoGrouping];
}
insertingText
is an ivar I'm using to keep track of whether text is being inserted or not. didHandleInsertOfString:
and didHandleInsertOfString:withSelection:
are the functions that actually end up doing the insertText:
calls to modify stuff. They're both pretty long, but I'll include an example at the end.
I'm only overriding replaceCharactersInRange:withString:
because I sometimes use that call to do modification of text, and it bypasses undo. However, you can hook it back up to undo by calling shouldChangeTextInRange:replacementString:
. So my override does that.
// We call replaceChractersInRange all over the place, and that does an end-run
// around Undo, unless you first call shouldChangeTextInRange:withString (it does
// the Undo stuff). Rather than sprinkle those all over the place, do it once
// here.
- (void) replaceCharactersInRange:(NSRange)range withString:(NSString*)aString
{
if( [self shouldChangeTextInRange:range replacementString:aString] ) {
[super replaceCharactersInRange:range withString:aString];
}
}
didHandleInsertOfString:
does a whole buncha stuff, but the gist of it is that it either inserts text (via insertText:
or replaceCharactersInRange:withString:
), and returns YES if it did any insertion, or returns NO if it does no insertion. It looks something like this:
- (BOOL) didHandleInsertOfString:(NSString*)string
{
if( [string length] == 0 ) return NO;
unichar character = [string characterAtIndex:0];
if( character == '(' || character == '[' || character == '{' || character == '\"' )
{
// (, [, {, ", ` : insert that, and end character.
unichar startCharacter = character;
unichar endCharacter;
switch( startCharacter ) {
case '(': endCharacter = ')'; break;
case '[': endCharacter = ']'; break;
case '{': endCharacter = '}'; break;
case '\"': endCharacter = '\"'; break;
}
if( character == '\"' ) {
// Double special case for quote. If the character immediately to the right
// of the insertion point is a number, we're done. That way if you type,
// say, 27", it works as you expect.
NSRange selectionRange = [self selectedRange];
if( selectionRange.location > 0 ) {
unichar lastChar = [[self string] characterAtIndex:selectionRange.location - 1];
if( [[NSCharacterSet decimalDigitCharacterSet] characterIsMember:lastChar] ) {
return NO;
}
}
// Special case for quote, if we autoinserted that.
// Type through it and we're done.
if( lastCharacterInserted == '\"' ) {
lastCharacterInserted = 0;
lastCharacterWhichCausedInsertion = 0;
[self moveRight:nil];
return YES;
}
}
NSString* replacementString = [NSString stringWithFormat:@"%c%c", startCharacter, endCharacter];
[self insertText:replacementString];
[self moveLeft:nil];
// Remember the character, so if the user deletes it we remember to also delete the
// one we inserted.
lastCharacterInserted = endCharacter;
lastCharacterWhichCausedInsertion = startCharacter;
if( lastCharacterWhichCausedInsertion == '{' ) {
justInsertedBrace = YES;
}
return YES;
}
// A bunch of other cases here...
return NO;
}
I would point out that this code isn't battle-tested: I've not used it in a shipping app (yet). But it is a trimmed down version of code I'm currently using in a project I intend to ship later this year. So far it appears to work well.
In order to really see how this works you probably want an example project, so I've posted one on github.
Right, this is by no means a perfect solution, but it is a solution of sorts.
The text storage updates the undo manager based off "groups". These groups cluster together a series of edits (which I can't quite remember of the top of my head), but I do remember that a new one is created when the selection is altered.
This leads to the possible solution of quickly changing the selection to something else and then reverting it back. Not an ideal solution but it may be enough to force the text storage to push a new state to the undo manager.
I shall take a bit more of a look and investigation and see if I can't find/trace exactly what happens.
edit: I should probably mention that it's been a while since I've used NSTextView and don't currently have access to Xcode on this machine to verify that this works still. Hopefully it will.