Cocoa: looking for a general strategy for programmatic manipulation of NSTextView storage without messing up undo
Asked Answered
O

2

17

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?

Odum answered 7/4, 2011 at 18:42 Comment(4)
My app does most of its manipulations in NSTextStorageDelegate's -textStorageWillProcessEditing:, but it only has to manipulate attributes, not characters. Still, that might be another thing for you to try.Redintegration
Please elaborate on those "general purpose storage modification method[s] that wraps changes in the storage with a call to shouldChangeTextInRange: and didChangeText:". Doing something within a delagate method which recursively calls the same delegate method sounds fishy.Dowski
@smallduck: As it has been roughly two years since I asked this question originally and I abandoned the project - in large part because I could not solve this undo issue satisfactorily - I can't really remember a lot of the details. I did spend considerable development time trying to work it out and tried many other approaches besides what I wrote above. I know it is possible because Xcode's editor does it, but I never found a way to programmatically change text without throwing the undo manager out of sync.Odum
You might want to take a look at IDEKit before you give up too fast. Most of it's custom, but the main editor class is still a subclass of NSTextView.Tradespeople
W
18

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.

Wagstaff answered 25/5, 2013 at 22:26 Comment(3)
Wow. Thanks so much for this generous and detailed answer! The general method you outlined - wrapping your set of changes in beginUndoGrouping: and endUndoGrouping is exactly what I had hoped would work in the NSTextview delegate methods. Logically it seems like it should, but obviously there is something opaque going on in there (I know... Apple? Opaque? never!). In any case, this answer alone is worth the bounty. The github example puts it way over the top. THANKS!Odum
Hey, thanks so much for this answer! I wasted a whole day and most of a night trying to do the same in shouldChangeTextIn:, failing constantly to make the UndoManager care for my changes. I was on the way to bed already when I got the idea to try insertText instead and felt so relieved to instantly find your answer!Strephonn
this is exactly what i needed as well- thanks! I was able to remove the calls to beginUndoGrouping and endUndoGrouping in insertText as well. otherwise the i would get undo steps for every keystroke. once i removed that, any custom edits i made to the text inside the didHandleInsertOfString worked perfectly and worked with undo/redo just right. thanks!Newbold
B
0

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.

Bonedry answered 25/5, 2013 at 9:16 Comment(1)
I can't say I follow your concept without seeing some code or pseudo-code, but I am aware of change groups. IIRC, I tried managing the specifics of the undo groups using calls to beginUndoGrouping and endUndoGrouping (or something like that). My understanding from reading the documentation for the undo manager is that wrapping programmatic change sets in those calls should have been all that was necessary to keep the change stack properly managed, but I couldn't get it to work for me.Odum

© 2022 - 2024 — McMap. All rights reserved.