5

If a NSTextView contains the following:

SELECT someTable.someColumn FROM someTable

And a user double-clicks someTable.someColumn, the entire thing gets selected (both sides of the period). In this specific case (a query editor), it would make more sense for either the someTable or the someColumn to be selected.

I've tried looking around to see if I can figure out a way to customize the selection, but I have been unable to so far.

At the moment what I'm thinking of doing is subclassing NSTextView and doing something such as:

- (void)mouseDown:(NSEvent *)theEvent
{
  if(theEvent.clickCount == 2)
  {
    // TODO: Handle double click selection.
  }
  else
  {
    [super mouseDown:theEvent];
  }
}

Does anyone have any thoughts or alternatives to this? (Is there another method I am missing that may be better for overriding)?

Kyle
  • 17,317
  • 32
  • 140
  • 246

3 Answers3

8

First of all, contrary to a previous answer, NSTextView's selectionRangeForProposedRange:granularity: method is not the correct place to override to achieve this. In Apple's "Cocoa Text Architecture" doc (https://developer.apple.com/library/prerelease/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html – see the "Subclassing NSTextView" section) Apple states explicitly "These mechanisms aren’t meant for changing language word definitions (such as what’s selected by a double click)." I'm not sure why Apple feels that way, but I suspect it is because selectionRangeForProposedRange:granularity: does not get any information regarding what part of the proposed range is the initial click point, versus what part is a place the user dragged to; making double-click-drags behave correctly might be hard to do with an override of this method. Perhaps there are other issues as well, I don't know; the doc is a bit cryptic. Perhaps Apple plans to make changes to the selection mechanism later that would break such overrides. Perhaps there are other aspects of defining what a "word" is that overriding here fails to address. Who knows; but it is generally a good idea to follow Apple's instructions when they make a statement like this.

Oddly, Apple's doc goes on to say "That detail of selection is handled at a lower (and currently private) level of the text system." I think that is outdated, because in fact the needed support does exist: the doubleClickAtIndex: method on NSAttributedString (in the NSAttributedStringKitAdditions category). This method is used (in the NSTextStorage subclass of NSAttributedString) by the Cocoa text system to determine word boundaries. Subclassing NSTextStorage is a bit tricky, so I'll provide a full implementation here for a subclass called MyTextStorage. Much of this code for subclassing NSTextStorage comes from Ali Ozer at Apple.

In MyTextStorage .h:

@interface MyTextStorage : NSTextStorage
- (id)init;
- (id)initWithAttributedString:(NSAttributedString *)attrStr;
@end

In MyTextStorage.m:

@interface MyTextStorage ()
{
    NSMutableAttributedString *contents;
}
@end

@implementation MyTextStorage

- (id)initWithAttributedString:(NSAttributedString *)attrStr
{
    if (self = [super init])
    {
        contents = attrStr ? [attrStr mutableCopy] : [[NSMutableAttributedString alloc] init];
    }
    return self;
}

- init
{
    return [self initWithAttributedString:nil];
}

- (void)dealloc
{
    [contents release];
    [super dealloc];
}

// The next set of methods are the primitives for attributed and mutable attributed string...

- (NSString *)string
{
    return [contents string];
}

- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRange *)range
{
    return [contents attributesAtIndex:location effectiveRange:range];
}

- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str
{
    NSUInteger origLen = [self length];
    [contents replaceCharactersInRange:range withString:str];
    [self edited:NSTextStorageEditedCharacters range:range changeInLength:[self length] - origLen];
}

- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range
{
    [contents setAttributes:attrs range:range];
    [self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
}

// And now the actual reason for this subclass: to provide code-aware word selection behavior

- (NSRange)doubleClickAtIndex:(NSUInteger)location
{
    // Start by calling super to get a proposed range.  This is documented to raise if location >= [self length]
    // or location < 0, so in the code below we can assume that location indicates a valid character position.
    NSRange superRange = [super doubleClickAtIndex:location];
    NSString *string = [self string];

    // If the user has actually double-clicked a period, we want to just return the range of the period.
    if ([string characterAtIndex:location] == '.')
        return NSMakeRange(location, 1);

    // The case where super's behavior is wrong involves the dot operator; x.y should not be considered a word.
    // So we check for a period before or after the anchor position, and trim away the periods and everything
    // past them on both sides.  This will correctly handle longer sequences like foo.bar.baz.is.a.test.
    NSRange candidateRangeBeforeLocation = NSMakeRange(superRange.location, location - superRange.location);
    NSRange candidateRangeAfterLocation = NSMakeRange(location + 1, NSMaxRange(superRange) - (location + 1));
    NSRange periodBeforeRange = [string rangeOfString:@"." options:NSBackwardsSearch range:candidateRangeBeforeLocation];
    NSRange periodAfterRange = [string rangeOfString:@"." options:(NSStringCompareOptions)0 range:candidateRangeAfterLocation];

    if (periodBeforeRange.location != NSNotFound)
    {
        // Change superRange to start after the preceding period; fix its length so its end remains unchanged.
        superRange.length -= (periodBeforeRange.location + 1 - superRange.location);
        superRange.location = periodBeforeRange.location + 1;
    }

    if (periodAfterRange.location != NSNotFound)
    {
        // Change superRange to end before the following period
        superRange.length -= (NSMaxRange(superRange) - periodAfterRange.location);
    }

    return superRange;
}

@end

And then the last part is actually using your custom subclass in your textview. If you have an NSTextView subclass as well, you can do this in its awakeFromNib method; otherwise, do this wherever else you get a chance, right after your nib loads; in the awakeFromNib call for a related window or controller, for example, or simply after your call to load the nib that contains the textview. In any case, you want to do this (where textview is your NSTextView object):

[[textview layoutManager] replaceTextStorage:[[[MyTextStorage alloc] init] autorelease]];

And with that, you should be good to go, unless I've made a mistake in transcibing this!

Finally, note that there is another method in NSAttributedString, nextWordFromIndex:forward:, that is used by Cocoa's text system when the user moves the insertion point to the next/previous word. If you want that sort of thing to follow the same word definition, you will need to subclass it as well. For my application I did not do that – I wanted next/previous word to move over whole a.b.c.d sequences (or more accurately I just didn't care) – so I don't have an implementation of that to share here. Left as an exercise for the reader.

Kyle
  • 17,317
  • 32
  • 140
  • 246
bhaller
  • 1,803
  • 15
  • 24
  • This is a great answer! There was a bug with text selection that I was never able to nail down and based on this, plus your comment on the other answer I was able to reproduce and fix it. Thank you very much for the fully details answer! – Kyle Sep 21 '15 at 14:11
  • 1
    FYI there was a minor bug where `setAttributes` was passing `NSTextStorageEditedCharacters` rather than `NSTextStorageEditedAttributes`. This was causing some issues for me during syntax highlighting as generally text is only processed when characters change. (Syntax highlighting adds font color attributes which was being seen as text changed rather than attributes changed). I've updated the post to fix the issue. – Kyle Nov 14 '15 at 12:36
  • 1
    Nice, thanks @Kyle. This fixes a bug in two places in my own code, too! The bug has a long history; the code for subclassing NSTextStorage posted by Ali Ozer of Apple, [here](http://www.cocoabuilder.com/archive/cocoa/14852-subclassing-nstextstorage-answered.html), has the same bug, which is where I got it from! – bhaller Nov 14 '15 at 16:33
1

In your subclass of NSTextView, you should override -selectionRangeForProposedRange:granularity:, something like :

-(NSRange)selectionRangeForProposedRange:(NSRange)proposedSelRange granularity:(NSSelectionGranularity)granularity
{
    if (granularity == NSSelectByWord)
    {
        NSRange doubleRange = [[self textStorage] doubleClickAtIndex:proposedSelRange.location];
        if (doubleRange.location != NSNotFound)
        {
            NSRange dotRange = [[[self textStorage] string] rangeOfString:@"." options:0 range:doubleRange];
            if (dotRange.location != NSNotFound)
            {
                // double click after '.' ?
                if (dotRange.location < proposedSelRange.location)
                    return NSMakeRange(dotRange.location + 1, doubleRange.length - (dotRange.location-doubleRange.location) - 1);
                else
                    return NSMakeRange(doubleRange.location, dotRange.location-doubleRange.location);
            }
        }
    }
    return [super selectionRangeForProposedRange:proposedSelRange granularity:granularity];
}
Emmanuel
  • 2,897
  • 1
  • 14
  • 15
  • Oh wow, I was just looking for a hint but this code does the trick! – Kyle Feb 25 '14 at 17:26
  • 1
    @Zenox You can still polish it a little bit… :) (don't need to fallback to send a message to `super`, if there is no dot, just return `doubleRange`) – Emmanuel Feb 25 '14 at 17:40
  • 2
    This is not a good solution for several reasons. First of all, it directly contravenes Apple's statement in their [Cocoa Text Architecture](https://developer.apple.com/library/prerelease/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html) doc that selectionRangeForProposedRange:granularity: is **not** meant for changing word definitions, "such as what's selected by a double click". Second, it simply works very poorly. Try typing "foo.bar.baz" and then double-clicking in "bar" or "baz". Try click-dragging to extend a selection over periods. Not good. – bhaller Sep 19 '15 at 17:24
0

Here is a customized implementation in Swift 5 of @bhaller code, thank you very much for that!

Note that it's not using string or NSMutableAttributedString due to memory efficiency, using another NSTextStorage is better. More info here

final class MyTextStorage: NSTextStorage {

    private var storage = NSTextStorage()

    // MARK: - Required overrides for NSTextStorage

    override var string: String {
        return storage.string
    }

    override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] {
        return storage.attributes(at: location, effectiveRange: range)
    }

    override func replaceCharacters(in range: NSRange, with str: String) {
        beginEditing()
        storage.replaceCharacters(in: range, with: str)
        edited(.editedCharacters, range: range, changeInLength: (str as NSString).length - range.length)
        endEditing()
    }

    override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
        beginEditing()
        storage.setAttributes(attrs, range: range)
        edited(.editedAttributes, range: range, changeInLength: 0)
        endEditing()
    }

    // MARK: - DOuble click functionality

    override func doubleClick(at location: Int) -> NSRange {

        // Call super to get location of the double click
        var range = super.doubleClick(at: location)
        let stringCopy = self.string

        // If the user double-clicked a period, just return the range of the period
        let locationIndex = stringCopy.index(stringCopy.startIndex, offsetBy: location)
        guard stringCopy[locationIndex] != "." else {
            return NSMakeRange(location, 1)
        }

        // The case where super's behavior is wrong involves the dot operator; x.y should not be considered a word.
        // So we check for a period before or after the anchor position, and trim away the periods and everything
        // past them on both sides. This will correctly handle longer sequences like foo.bar.baz.is.a.test.
        let candidateRangeBeforeLocation = NSMakeRange(range.location, location - range.location)
        let candidateRangeAfterLocation = NSMakeRange(location + 1, NSMaxRange(range) - (location + 1))
        let periodBeforeRange = (stringCopy as NSString).range(of: ".", options: .backwards, range: candidateRangeBeforeLocation)
        let periodAfterRange = (stringCopy as NSString).range(of: ".", options: [], range: candidateRangeAfterLocation)

        if periodBeforeRange.location != NSNotFound {
            // Change range to start after the preceding period; fix its length so its end remains unchanged
            range.length -= (periodBeforeRange.location + 1 - range.location)
            range.location = periodBeforeRange.location + 1
        }
        if periodAfterRange.location != NSNotFound {
            // Change range to end before the following period
            range.length -= (NSMaxRange(range) - periodAfterRange.location);
        }

        return range
    }
}

EDIT: Google helped me find find this article - using Swift version is not really recommended due to performance issues.

Jakub Kašpar
  • 228
  • 4
  • 8