0

It's common to have a text editor for code or other structured content that balances delimiters of some sort; when you double click on a { it selects to the matching }, or similarly for ( ) pairs, [ ] pairs, etc. How can I implement this behavior in NSTextView in Cocoa/Obj-C?

(I will be posting an answer momentarily, since I found nothing on SO about this and spent today implementing a solution. Better answers are welcome.)

ADDENDUM:

This is not the same as this question, which is about NSTextField and is primarily concerned with NSTextField and field editor issues. If that question is solved by substituting a custom NSTextView subclass into the field editor, then that custom subclass could use the solution given here, of course; but there might be many other ways to solve the problem for NSTextField, and substituting a custom NSTextView subclass into the field editor is not obviously the right solution to that problem, and in any case a programmer concerned with delimiter balancing in NSTextView (which is presumably the more common problem) could care less about all of those NSTextField and field editor issues. So that is a different question – although I will add a link from that question to this one, as one possible direction it could go.

This is also not the same as this question, which is really about changing the definition of a "word" in NSTextView when a double-click occurs. As per Apple's documentation, these are different problems with different solutions; for delimiter-balancing (this question) Apple specifically recommends the use of NSTextView's selectionRangeForProposedRange:granularity: method, whereas for changing the definition of a word (that question) Apple specifically states that the selectionRangeForProposedRange:granularity: method should not be used.

Community
  • 1
  • 1
bhaller
  • 1,803
  • 15
  • 24
  • You also posted an [answer](https://stackoverflow.com/a/32671117/1312143) to another duplicate question, so I'm not sure why you're posting this. – Ken Thomases Sep 20 '15 at 07:55
  • @KenThomases, these are not duplicates. Please look more carefully. The question you have marked as a duplicate of this one is about NSTextField, not NSTextView, and is primarily concerned with dealing with field editor issues (as usual with NSTextField). That is a different class with different issues. Look at the answer posted below, which uses selectionRangeForProposedRange:granularity: as specifically recommended by Apple for NSTextView. The NSTextField question cannot use that (at least not directly), since that is not an NSTextField method. – bhaller Sep 20 '15 at 12:13
  • The other question that you mention in your comment above as a duplicate is also not a duplicate. That other question is about customizing double-click selection to obey different word boundaries; to treat a period as a word delimiter in all cases, specifically. As per Apple's documentation, as reviewed carefully in both answers, that is a different problem requiring a different solution; Apple states that selectionRangeForProposedRange:granularity: should specifically **not** be used to solve that problem (but should be used for delimiter-balancing). – bhaller Sep 20 '15 at 12:17
  • @KenThomases I have edited the question to make these distinctions more clear. If you agree, then we can probably delete all these comments; I'm not sure what the etiquette is regarding that on SO. – bhaller Sep 20 '15 at 12:57
  • I've reopened it. I'm not sure I agree it's really all that different than the first one and, despite the documentation you linked, it doesn't seem to me that `-selectionRangeForProposedRange:granularity:` is appropriate for this task, given the gymnastics you have to go through to figure out if it's called for a double-click. But, whatever. – Ken Thomases Sep 20 '15 at 13:11
  • Most of the gymnastics are really just an abundance of caution. The use of `inEligibleDoubleClick` and the override of `mouseDown:` in my answer could both be removed entirely and I think it would still work. It would just be less robust, because it would be making stronger assumptions about how `NSTextView` click handling works, and it would not fail as gracefully if those assumptions were false. I agree that `selectionRangeForProposedRange:granularity:` is maybe not the best-designed API that Apple has ever produced; but I think it is clearly the best tool for the job nevertheless. – bhaller Sep 20 '15 at 14:21

1 Answers1

1

In their Cocoa Text Architecture Guide (https://developer.apple.com/library/prerelease/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html), Apple suggests subclassing NSTextView and overriding selectionRangeForProposedRange:granularity: to achieve this sort of thing; they even say "For example, in a code editor you can provide a delegate that extends a double click on a brace or parenthesis character to its matching delimiter." However, it is not immediately clear how to achieve this, since you want the delimiter match to happen only at after a simple double-click on a delimiter, not after a double-click-drag or even a double-click-hold-release.

The best solution I could come up with involves overriding mouseDown: as well, and doing a little bookkeeping about the state of affairs. Maybe there is a simpler way. I've left out the core part of the code where the delimiter match actually gets calculated; that will depend on what delimiters you're matching, what syntactical complexities (strings, comments) might exist, and so forth. In my code I actually call a tokenizer to get a token stream, and I use that to find the matching delimiter. YMMV. So, here's what I've got:

In your NSTextView subclass interface (or class extension, better yet):

// these are used in selectionRangeForProposedRange:granularity:
// to balance delimiters properly
BOOL inEligibleDoubleClick;
NSTimeInterval doubleDownTime;

In your NSTextView subclass implementation:

- (void)mouseDown:(NSEvent *)theEvent
{
    // Start out willing to work with a double-click for delimiter-balancing;
    // see selectionRangeForProposedRange:proposedCharRange granularity: below
    inEligibleDoubleClick = YES;

    [super mouseDown:theEvent];
}

- (NSRange)selectionRangeForProposedRange:(NSRange)proposedCharRange
    granularity:(NSSelectionGranularity)granularity
{
    if ((granularity == NSSelectByWord) && inEligibleDoubleClick)
    {
        // The proposed range has to be zero-length to qualify
        if (proposedCharRange.length == 0)
        {
            NSEvent *event = [NSApp currentEvent];
            NSEventType eventType = [event type];
            NSTimeInterval eventTime = [event timestamp];

            if (eventType == NSLeftMouseDown)
            {
                // This is the mouseDown of the double-click; we do not want
                // to modify the selection here, just log the time
                doubleDownTime = eventTime;
            }
            else if (eventType == NSLeftMouseUp)
            {
                // After the double-click interval since the second mouseDown,
                // the mouseUp is no longer eligible
                if (eventTime - doubleDownTime <= [NSEvent doubleClickInterval])
                {
                    NSString *scriptString = [[self textStorage] string];

                    ...insert delimiter-finding code here...
                    ...return the matched range, or NSBeep()...
                }
                else
                {
                    inEligibleDoubleClick = false;
                }
            }
            else
            {
                inEligibleDoubleClick = false;
            }
        }
        else
        {
            inEligibleDoubleClick = false;
        }
    }

    return [super selectionRangeForProposedRange:proposedCharRange
        granularity:granularity];
}

It's a little fragile, because it relies on NSTextView's tracking working in a particular way and calling out to selectionRangeForProposedRange:granularity: in a particular way, but the assumptions are not large; I imagine it's pretty robust.

bhaller
  • 1,803
  • 15
  • 24