Clickable Tweet Links, Hashtags & Usernames In A Custom NSTextView

Download TweetView.xcodeproj and then follow along below.

One of the challenges of creating a Twitter app for Mac is exactly how to build the timeline interface. Not the design, although that's very challenging as well, but the implementation of the timeline since it's very important to the overall stability of your app. Beak used a Webview because when I built it I didn't know much about AppKit or custom interface drawing. It's good for when you're just starting out, but if you really want to build great apps, you're going to eventually need to learn how to draw things natively.

Earlier this year I started work on a totally native implementation of Beak and the timeline code was perplexing. I played around with using an NSTableView (NSCells suck), an NSCollectionView (uses too much memory to have all tweet views in the timeline at once, also, inflexible), a custom NSScrollView (built like UIKit's UITableView with reusable views, this was the best solution), but in the end the most interesting part was drawing the individual tweets.

Drawing plain text with the same style throughout isn't that tricky. Drawing an NSAttributedString with some custom styles isn't that tricky either. The tricky part is that there are various interesting parts of a tweet's text (links, hashtags, usernames) and they all need custom styles and the ability for the user to click on them to initiate an action.

Step 1: Initial Paragraph Style

The content within our NSTextView is an NSAttributedString. What's an attributed string? It's actually a very simple concept: it's a string with some key-value pairs attached to parts of the string. These key-value pairs could be anything, but if you're adding styles, the key has to be one of a variety of pre-defined values that AppKit provides. For example, say a string has three words in it and you want each word to be a different color. You could set a color for the special key of NSForegroundColorAttributeName for each word and then you're golden, it'll be drawn with three different colors. There are a number of these stylistic attributes and a full list can be found here.

You can have more than one attribute defined for a particular word or range of characters. By default, we're going to set a number of styles for the entire range of the status string. These include a paragraph style (line height, spacing, text alignment), a text shadow to make the text appear inset on the gray background, a color, font size and more.

NSShadow *textShadow = [[NSShadow alloc] init];
[textShadow setShadowColor:[NSColor colorWithDeviceWhite:1 alpha:.8]];
[textShadow setShadowBlurRadius:0];
[textShadow setShadowOffset:NSMakeSize(0, -1)];
							 
NSMutableParagraphStyle *paragraphStyle =
  [[NSParagraphStyle defaultParagraphStyle] mutableCopy];
[paragraphStyle setMinimumLineHeight:22];
[paragraphStyle setMaximumLineHeight:22];
[paragraphStyle setParagraphSpacing:0];
[paragraphStyle setParagraphSpacingBefore:0];
[paragraphStyle setTighteningFactorForTruncation:4];
[paragraphStyle setAlignment:NSNaturalTextAlignment];
[paragraphStyle setLineBreakMode:NSLineBreakByWordWrapping];

// Our initial set of attributes that are applied to the full string length
NSDictionary *fullAttributes = [NSDictionary dictionaryWithObjectsAndKeys:
  [NSColor colorWithDeviceHue:.53 saturation:.13 brightness:.26 alpha:1],
  NSForegroundColorAttributeName,
  textShadow, NSShadowAttributeName,
  [NSCursor arrowCursor], NSCursorAttributeName,
  [NSNumber numberWithFloat:0.0], NSKernAttributeName,
  [NSNumber numberWithInt:0], NSLigatureAttributeName,
  paragraphStyle, NSParagraphStyleAttributeName,
  [NSFont systemFontOfSize:14.0], NSFontAttributeName, nil];

[attributedStatusString addAttributes:fullAttributes
  range:NSMakeRange(0, [statusString length])];

[textShadow release];
[paragraphStyle release];

Step 2: Finding The Interesting Parts

We're looking for links, usernames and hashtags, so the best and most customizable way to do this is to use regular expressions to parse through the string and pluck them out.

I used RegexKitLite and the libicucore framework to provide the backing for the regular expression code, and then wrote the following methods to return an NSArray holding strings that matched the expression.

- (NSArray *)scanStringForLinks:(NSString *)string {
    return [string componentsMatchedByRegex:@"\\b(([\\w-]+://?
      |www[.])[^\\s()<>]+(?:\\([\\w\\d]+\\)|([^[:punct:]\\s]|/)))"];
}

- (NSArray *)scanStringForUsernames:(NSString *)string {
    return [string componentsMatchedByRegex:@"@{1}([-A-Za-z0-9_]{2,})"];
}

- (NSArray *)scanStringForHashtags:(NSString *)string {
    return [string componentsMatchedByRegex:@"[\\s]{1,}#{1}([^\\s]{2,})"];
}

These regular expressions aren't as great as they would need to be to ship, but they do the job for the purposes of this tutorial. There are better regular expressions out there for matching URLs in strings, and if you find one, make sure to adhere to the strict escaping rules outlined in the RegexKitLite documentation.

Step 3: Do Something With The Matched Strings

We have arrays holding the interesting bits of our tweet status string, so what do we do now? I iterated across each array, found the character range where the string exists, then added custom attributes to the NSAttributedString at that exact position to style it differently.

for (NSString *linkMatchedString in linkMatches) {
    NSRange range = [statusString rangeOfString:linkMatchedString];
    if( range.location != NSNotFound ) {
        // Add custom attribute of LinkMatch to indicate where our URLs are found.
        // Could be blue or any other color.
        NSDictionary *linkAttr = [[NSDictionary alloc] initWithObjectsAndKeys:
          [NSCursor pointingHandCursor], NSCursorAttributeName,
          [NSColor blueColor], NSForegroundColorAttributeName,
          [NSFont boldSystemFontOfSize:14.0], NSFontAttributeName,
          linkMatchedString, @"LinkMatch", nil];
			
        [attributedStatusString addAttributes:linkAttr range:range];
        [linkAttr release];
    }
}

This is all pretty normal except for the final attribute I add for the custom key "LinkMatch". Here, I attach the actual matched string as the object attached to the "LinkMatch" key. Now, our attributes are not only storing style information for this link, they're also holding the URL itself. This will come in handy in a bit.

I also iterated across the username and hashtag matches and added the custom attributes "UsernameMatch" and "HashtagMatch" respectively.

Step 4: Display In An NSTextView

At this point our NSAttributedString is good to go. It has default styling across its full length, and it also has custom styling for individual parts defined by our regular expression. If we display it within an NSTextView it should look perfect, and, from the screenshot at the top of the entry, you can see that it does.

Displaying a tweet is all well and good, but what about user interaction? How do we trigger custom actions when a user clicks on the links, hashtags and usernames within the status text? Ah, that's where the custom key-value pairs described up above come in. What we want to do is know when the user clicks on anything inside the tweet text, and then identify the exact mouse coordinate of the click. Using this coordinate we can then make some calls to figure out what was under their mouse when they clicked and if it was a part of the text we want to take an action on.

First, we need to be notified when clicks happen within our text view. I found that the easiest way to do this is to subclass NSTextView and override the -mouseDown: selector to inject our own functionality. Here's the first part of that code.

@implementation TVTextView

- (void)mouseDown:(NSEvent *)theEvent {
    // Here's where we're going to do stuff
    [super mouseDown:theEvent];
}

@end

Notice that at the end of the selector we pass the event back up to super. This is so that the default NSTextView mouse-handling code can fire, like selecting text with a mouse cursor. If we didn't pass the event up to the super class then all built-in mouse click actions would be broken.

Now for the meat of our implementation. We have to identify the coordinates of the mouse event, then translate that into the part of the string that falls underneath the mouse cursor.

NSPoint point = [self convertPoint:[theEvent locationInWindow] fromView:nil];
NSInteger charIndex = [self characterIndexForInsertionAtPoint:point];

The characterIndexForInsertionAtPoint: call is the key to this entire tutorial. Starting in Leopard, NSTextView provides this neat functionality to retrieve the character position for a given coordinate, that is, if you provide a specific point it will tell you at how many characters into the string it occurred. Since we can retrieve the index of the mouse event, we can then make the following call to retrieve the attributes for that specific position:

NSDictionary *attributes = [[self attributedString] attributesAtIndex:charIndex effectiveRange:NULL];

Why access the attributes? Well, we tucked away some interesting metadata back in our original attributed string definition, so we can get that back and immediately know what username, hashtag or URL is sitting underneath the user's mouse cursor when they click and act on it accordingly. Presto!

if( [attributes objectForKey:@"LinkMatch"] != nil ) {
    // Could open this URL...
    NSLog( @"LinkMatch: %@", [attributes objectForKey:@"LinkMatch"] );
}
		
if( [attributes objectForKey:@"UsernameMatch"] != nil ) {
    // Could show a Twitter profile HUD overlay...
    NSLog( @"UsernameMatch: %@", [attributes objectForKey:@"UsernameMatch"] );
}
		
if( [attributes objectForKey:@"HashtagMatch"] != nil ) {
    // Could flip to the Search view and search for this hashtag...
    NSLog( @"HashtagMatch: %@", [attributes objectForKey:@"HashtagMatch"] );
}

And that's it! Custom-styled tweet text displayed in an NSTextView subclass that can identify when users click on different parts of the tweet and act accordingly.

Download the full Xcode project here and, as noted in the source code files, you can do whatever you want with the code.

Featured Project

Design Then Code