Cocoa:寻找一种以编程方式操作 NSTextView 存储而不弄乱撤消的通用策略

发布于 2024-10-30 18:22:14 字数 1285 浏览 3 评论 0原文

我正在用 cocoa 编写一个特殊用途的文本编辑器,它可以执行自动文本替换、内联文本完成(ala Xcode)等功能。

我需要能够以编程方式操作 NSTextView< /code> 的 NSTextStorage 响应 1) 用户输入,2) 用户粘贴,3) 用户删除文本。

我尝试了两种不同的通用方法,它们都导致 NSTextView 的本机撤消管理器以不同的方式失去同步。在每种情况下,我仅使用 NSTextView 委托方法。我一直在努力避免子类化 NSTextviewNSTextStorage (尽管如果有必要我会子类化)。

我尝试的第一种方法是在 textView delegatetextDidChange 方法中进行操作。在该方法中,我分析了 textView 中发生的更改,然后调用一个通用方法来修改文本,该方法通过调用 shouldChangeTextInRange: 将更改包装在 textStorage 中和didChangeText:。一些程序更改允许干净撤消,但有些则不允许。

我尝试的第二种方法(可能更直观,因为它在文本实际出现在 textView 中之前进行了更改)是从 delegate内部进行操作shouldChangeTextInRange: 方法,再次使用相同的通用存储修改方法,通过调用 shouldChangeTextInRange:didChangeText: 来包装存储中的更改。由于这些更改最初是从 shouldChangeTextInRange: 内部触发的,因此我设置了一个标志,告诉内部调用 shouldChangeTextInRange: 被忽略,以免进入递归黑洞。同样,一些程序性更改允许干净撤消,但有些则不允许(尽管这次不同,并且以不同的方式)。

有了这些背景,我的问题是,有人可以向我指出一种以编程方式操作 NSTextview 存储的通用策略,以保持撤消管理器的干净和同步吗?

我应该在哪个 NSTextview 委托方法中关注 textView 中的文本更改(通过键入、粘贴或删除)并对 NSTextStorage 进行操作?或者是通过子类化 NSTextViewNSTextStorage 来实现此目的的唯一干净方法?

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?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

千仐 2024-11-06 18:22:14

我最初最近发布了一个类似问题(谢谢OP从那里指向这个问题)。

这个问题从来没有得到真正让我满意的答案,但我确实有一个解决我原来问题的方法,我相信这也适用于这个问题。

我的解决方案不是使用委托方法,而是重写 NSTextView。所有修改都是通过重写 insertText:replaceCharactersInRange:withString: 完成的。

我的 insertText: 重写检查要插入的文本,并决定是否插入插入未修改的内容,或在插入之前进行其他更改。无论如何,都会调用 super 的 insertText: 来执行实际插入。此外,我的 insertText: 会进行自己的撤消分组,基本上是通过在插入文本之前调用 beginUndoGrouping: 和之后调用 endUndoGrouping: 来实现。这听起来太简单了,但对我来说似乎很有效。结果是,每个插入的字符都会执行一次撤消操作(这是多少个“真正的”文本编辑器的工作方式 - 例如,请参阅 TextMate)。此外,这使得额外的编程修改与触发它们的操作成为原子的。例如,如果用户键入 {,而我的 insertText: 以编程方式插入 },则两者都包含在同一撤消分组中,因此一个撤消操作会同时撤消两者。我的 insertText: 看起来像这样:

- (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 是一个 ivar,我用来跟踪文本是否被插入。 didHandleInsertOfString:didHandleInsertOfString:withSelection: 是实际最终执行 insertText: 调用来修改内容的函数。它们都很长,但我会在最后添加一个示例。

我只是重写 replaceCharactersInRange:withString: 因为我有时使用该调用来修改文本,并且它会绕过撤消。不过,您可以通过调用 shouldChangeTextInRange:replacementString: 将其挂起以进行撤消。所以我的覆盖就是这样做的。

// 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: 做了很多事情,但其要点是它插入文本(通过 insertText:replaceCharactersInRange:withString:) ,如果进行了任何插入,则返回 YES,如果没有进行插入,则返回 NO。它看起来像这样:

- (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;
}

我要指出的是,这段代码没有经过实际测试:我还没有在运输应用程序中使用过它。但这是我目前正在一个项目中使用的代码的精简版本,我打算在今年晚些时候发布。到目前为止,它似乎运作良好。

为了真正了解它是如何工作的,您可能需要一个示例项目,因此我在 github 上发布了一个示例项目

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.

四叶草在未来唯美盛开 2024-11-06 18:22:14

是的,这绝不是一个完美的解决方案,但它是某种解决方案。

文本存储根据“组”更新撤消管理器。这些组将一系列编辑聚集在一起(我不太记得我的头顶),但我确实记得当选择更改时会创建一个新的。

这导致了一种可能的解决方案:快速将选择更改为其他内容,然后将其恢复回来。这不是一个理想的解决方案,但它可能足以强制文本存储将新状态推送到撤消管理器。

我将进行更多的观察和调查,看看我是否无法找到/追踪到底发生了什么。

编辑:我可能应该提到,自从我使用 NSTextView 以来已经有一段时间了,并且目前无法访问这台机器上的 Xcode 来验证它是否仍然有效。希望它会。

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.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文