blob: c9ff4273252aaf2db6bcb09efc45b21518d40d90 [file] [log] [blame]
//
// INDSequentialTextSelectionManager.m
// INDSequentialTextSelectionManager
//
// Created by Indragie Karunaratne on 2014-03-02.
// Copyright (c) 2014 Indragie Karunaratne. All rights reserved.
//
#import "INDSequentialTextSelectionManager.h"
#import <objc/runtime.h>
static NSUInteger INDCharacterIndexForTextViewEvent(NSEvent *event, NSTextView *textView)
{
NSView *contentView = event.window.contentView;
NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
NSPoint textPoint = [textView convertPoint:point fromView:contentView];
return [textView characterIndexForInsertionAtPoint:textPoint];
}
static NSRange INDForwardRangeForIndices(NSUInteger idx1, NSUInteger idx2) {
NSRange range;
if (idx2 >= idx1) {
range = NSMakeRange(idx1, idx2 - idx1);
} else if (idx2 < idx1) {
range = NSMakeRange(idx2, idx1 - idx2);
} else {
range = NSMakeRange(NSNotFound, 0);
}
return range;
}
static void * INDUniqueIdentifierKey = &INDUniqueIdentifierKey;
@interface NSTextView (INDUniqueIdentifiers)
@property (nonatomic, copy) NSString *ind_uniqueIdentifier;
@end
@implementation NSTextView (INDUniqueIdentifiers)
- (void)setInd_uniqueIdentifier:(NSString *)ind_uniqueIdentifier
{
objc_setAssociatedObject(self, INDUniqueIdentifierKey, ind_uniqueIdentifier, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (NSString *)ind_uniqueIdentifier
{
return objc_getAssociatedObject(self, INDUniqueIdentifierKey);
}
@end
@interface INDAttributeRange : NSObject
@property (nonatomic, copy, readonly) NSString *attribute;
@property (nonatomic, strong, readonly) id value;
@property (nonatomic, assign, readonly) NSRange range;
- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range;
@end
@implementation INDAttributeRange
- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range
{
if ((self = [super init])) {
_attribute = [attribute copy];
_value = value;
_range = range;
}
return self;
}
@end
static void * INDBackgroundColorRangesKey = &INDBackgroundColorRangesKey;
static void * INDHighlightedRangeKey = &INDHighlightedRangeKey;
#define IND_DISABLED_SELECTED_TEXT_BG_COLOR [NSColor colorWithDeviceRed:0.83 green:0.83 blue:0.83 alpha:1.0]
@interface NSTextView (INDSelectionHighlight)
@property (nonatomic, strong) NSArray *ind_backgroundColorRanges;
@property (nonatomic, assign) NSRange ind_highlightedRange;
@end
@implementation NSTextView (INDSelectionHighlight)
- (void)ind_highlightSelectedTextInRange:(NSRange)range drawActive:(BOOL)active
{
if (self.ind_backgroundColorRanges == nil) {
[self ind_backupBackgroundColorState];
}
self.ind_highlightedRange = range;
NSColor *selectedColor = nil;
if (active) {
selectedColor = self.selectedTextAttributes[NSBackgroundColorAttributeName] ?: NSColor.selectedTextBackgroundColor;
} else {
selectedColor = IND_DISABLED_SELECTED_TEXT_BG_COLOR;
}
[self.textStorage beginEditing];
[self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.textStorage.length)];
[self.textStorage addAttribute:NSBackgroundColorAttributeName value:selectedColor range:range];
[self.textStorage endEditing];
[self setNeedsDisplay:YES];
}
- (void)ind_backupBackgroundColorState
{
NSMutableArray *ranges = [NSMutableArray array];
NSString *attribute = NSBackgroundColorAttributeName;
[self.textStorage enumerateAttribute:attribute inRange:NSMakeRange(0, self.textStorage.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
if (value == nil) return;
INDAttributeRange *attrRange = [[INDAttributeRange alloc] initWithAttribute:attribute value:value range:range];
[ranges addObject:attrRange];
}];
self.ind_backgroundColorRanges = ranges;
}
- (void)ind_deselectHighlightedText
{
[self.textStorage beginEditing];
[self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.string.length)];
NSArray *ranges = self.ind_backgroundColorRanges;
for (INDAttributeRange *range in ranges) {
[self.textStorage addAttribute:range.attribute value:range.value range:range.range];
}
[self.textStorage endEditing];
[self setNeedsDisplay:YES];
self.ind_backgroundColorRanges = nil;
self.ind_highlightedRange = NSMakeRange(0, 0);
}
- (void)setInd_backgroundColorRanges:(NSArray *)ind_backgroundColorRanges
{
objc_setAssociatedObject(self, INDBackgroundColorRangesKey, ind_backgroundColorRanges, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSArray *)ind_backgroundColorRanges
{
return objc_getAssociatedObject(self, INDBackgroundColorRangesKey);
}
- (void)setInd_highlightedRange:(NSRange)ind_highlightedRange
{
objc_setAssociatedObject(self, INDHighlightedRangeKey, [NSValue valueWithRange:ind_highlightedRange], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSRange)ind_highlightedRange
{
return [objc_getAssociatedObject(self, INDHighlightedRangeKey) rangeValue];
}
@end
@interface INDTextViewSelectionRange : NSObject
@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
@property (nonatomic, assign, readonly) NSRange range;
@property (nonatomic, copy, readonly) NSAttributedString *attributedText;
- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range;
@end
@implementation INDTextViewSelectionRange
- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range
{
if ((self = [super init])) {
_textViewIdentifier = [textView.ind_uniqueIdentifier copy];
_range = range;
_attributedText = [[textView.attributedString attributedSubstringFromRange:range] copy];
}
return self;
}
@end
@interface INDTextViewSelectionSession : NSObject
@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
@property (nonatomic, assign, readonly) NSUInteger characterIndex;
@property (nonatomic, strong, readonly) NSDictionary *selectionRanges;
@property (nonatomic, assign) NSPoint windowPoint;
- (void)addSelectionRange:(INDTextViewSelectionRange *)range;
- (void)removeSelectionRangeForTextView:(NSTextView *)textView;
@end
@implementation INDTextViewSelectionSession {
NSMutableDictionary *_selectionRanges;
}
@synthesize selectionRanges = _selectionRanges;
- (id)initWithTextView:(NSTextView *)textView event:(NSEvent *)event
{
if ((self = [super init])) {
_textViewIdentifier = [textView.ind_uniqueIdentifier copy];
_characterIndex = INDCharacterIndexForTextViewEvent(event, textView);
_selectionRanges = [NSMutableDictionary dictionary];
_windowPoint = event.locationInWindow;
}
return self;
}
- (void)addSelectionRange:(INDTextViewSelectionRange *)range
{
NSParameterAssert(range.textViewIdentifier);
_selectionRanges[range.textViewIdentifier] = range;
}
- (void)removeSelectionRangeForTextView:(NSTextView *)textView
{
NSParameterAssert(textView.ind_uniqueIdentifier);
[_selectionRanges removeObjectForKey:textView.ind_uniqueIdentifier];
}
@end
@interface INDTextViewMetadata : NSObject
@property (nonatomic, strong, readonly) NSTextView *textView;
@property (nonatomic, copy, readonly) INDAttributedTextTransformationBlock transformationBlock;
- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock;
@end
@implementation INDTextViewMetadata
- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock
{
if ((self = [super init])) {
_textView = textView;
_transformationBlock = [transformationBlock copy];
}
return self;
}
@end
@interface INDSequentialTextSelectionManager ()
@property (nonatomic, strong, readonly) NSMutableDictionary *textViews;
@property (nonatomic, strong, readonly) NSMutableOrderedSet *sortedTextViews;
@property (nonatomic, strong) INDTextViewSelectionSession *currentSession;
@property (nonatomic, strong) NSAttributedString *cachedAttributedText;
@property (nonatomic, strong) id eventMonitor;
@property (nonatomic, assign, getter = isFirstResponder) BOOL firstResponder;
@end
@implementation INDSequentialTextSelectionManager
#pragma mark - Iniialization
- (id)init
{
if ((self = [super init])) {
_textViews = [NSMutableDictionary dictionary];
_sortedTextViews = [NSMutableOrderedSet orderedSet];
_eventMonitor = [self addLocalEventMonitor];
}
return self;
}
#pragma mark - Cleanup
- (void)dealloc
{
[NSEvent removeMonitor:_eventMonitor];
}
#pragma mark - Events
- (BOOL)handleLeftMouseDown:(NSEvent *)event
{
// Allow for correct handling of double clicks on text views.
if (event.clickCount == 1) {
[self endSession];
NSTextView *textView = [self validTextViewForEvent:event];
// Ignore if the text view is not "owned" by this manager, or if it is being
// edited at the time of this event.
if (textView && textView.window.firstResponder != textView) {
self.currentSession = [[INDTextViewSelectionSession alloc] initWithTextView:textView event:event];
return YES;
}
}
return NO;
}
- (BOOL)handleLeftMouseUp:(NSEvent *)event
{
if (self.currentSession == nil) return NO;
[event.window makeFirstResponder:self];
NSTextView *textView = [self validTextViewForEvent:event];
if (textView != nil) {
// Handle link clicks properly.
NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
if (index < textView.string.length) {
NSDictionary *attributes = [textView.attributedString attributesAtIndex:index effectiveRange:NULL];
id link = attributes[NSLinkAttributeName];
// From documentation, NSLinkAttributeName could be either an NSString * or NSURL *
if (link != nil) {
[textView clickedOnLink:link atIndex:index];
}
}
}
return YES;
}
- (BOOL)handleLeftMouseDragged:(NSEvent *)event
{
if (self.currentSession == nil) return NO;
NSTextView *textView = [self validTextViewForEvent:event];
if (textView != nil) {
[textView.window makeFirstResponder:textView];
NSSelectionAffinity affinity = (event.locationInWindow.y < self.currentSession.windowPoint.y) ? NSSelectionAffinityDownstream : NSSelectionAffinityUpstream;
self.currentSession.windowPoint = event.locationInWindow;
NSUInteger current;
NSString *identifier = self.currentSession.textViewIdentifier;
if ([textView.ind_uniqueIdentifier isEqualTo:identifier]) {
current = self.currentSession.characterIndex;
} else {
INDTextViewMetadata *meta = self.textViews[identifier];
NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
NSUInteger end = [self.sortedTextViews indexOfObject:textView];
current = (end >= start) ? 0 : textView.string.length;
}
NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
NSRange range = INDForwardRangeForIndices(index, current);
[self setSelectionRangeForTextView:textView withRange:range];
[self processCompleteSelectionsForTargetTextView:textView affinity:affinity];
}
return YES;
}
- (BOOL)handleRightMouseDown:(NSEvent *)event
{
if (self.currentSession == nil) return NO;
[event.window makeFirstResponder:self];
NSTextView *textView = [self validTextViewForEvent:event];
if (textView != nil) {
NSMenu *menu = [self menuForEvent:event];
[NSMenu popUpContextMenu:menu withEvent:event forView:textView];
}
return YES;
}
- (id)addLocalEventMonitor
{
return [NSEvent addLocalMonitorForEventsMatchingMask:NSLeftMouseDownMask | NSLeftMouseDraggedMask | NSLeftMouseUpMask | NSRightMouseDownMask handler:^NSEvent *(NSEvent *event) {
switch (event.type) {
case NSLeftMouseDown:
return [self handleLeftMouseDown:event] ? nil : event;
case NSLeftMouseDragged:
return [self handleLeftMouseDragged:event] ? nil : event;
case NSLeftMouseUp:
return [self handleLeftMouseUp:event] ? nil : event;
case NSRightMouseDown:
return [self handleRightMouseDown:event] ? nil : event;
default:
return event;
}
}];
}
- (NSTextView *)validTextViewForEvent:(NSEvent *)event
{
NSView *contentView = event.window.contentView;
NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
NSView *view = [contentView hitTest:point];
if ([view isKindOfClass:NSTextView.class]) {
NSTextView *textView = (NSTextView *)view;
NSString *identifier = textView.ind_uniqueIdentifier;
return (textView.isSelectable && identifier && self.textViews[identifier]) ? textView : nil;
}
return nil;
}
#pragma mark - NSResponder
- (NSAttributedString *)cachedAttributedText
{
if (_cachedAttributedText == nil) {
_cachedAttributedText = [self buildAttributedStringForCurrentSession];
}
return _cachedAttributedText;
}
- (void)copy:(id)sender
{
NSPasteboard *pboard = NSPasteboard.generalPasteboard;
[pboard clearContents];
[pboard writeObjects:@[self.cachedAttributedText]];
}
- (NSMenu *)buildSharingMenu
{
NSMenu *shareMenu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Share", nil)];
NSArray *services = [NSSharingService sharingServicesForItems:@[self.cachedAttributedText]];
for (NSSharingService *service in services) {
NSMenuItem *item = [shareMenu addItemWithTitle:service.title action:@selector(share:) keyEquivalent:@""];
item.target = self;
item.image = service.image;
item.representedObject = service;
}
return shareMenu;
}
- (NSMenu *)menuForEvent:(NSEvent *)theEvent
{
NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Text Actions", nil)];
NSMenuItem *copy = [menu addItemWithTitle:NSLocalizedString(@"Copy", nil) action:@selector(copy:) keyEquivalent:@""];
copy.target = self;
[menu addItem:NSMenuItem.separatorItem];
NSMenuItem *share = [menu addItemWithTitle:NSLocalizedString(@"Share", nil) action:nil keyEquivalent:@""];
share.submenu = [self buildSharingMenu];
return menu;
}
- (void)share:(NSMenuItem *)item
{
NSSharingService *service = item.representedObject;
[service performWithItems:@[self.cachedAttributedText]];
}
- (void)rehighlightSelectedRangesAsActive:(BOOL)active
{
NSArray *ranges = self.currentSession.selectionRanges.allValues;
for (INDTextViewSelectionRange *range in ranges) {
INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
[meta.textView ind_highlightSelectedTextInRange:range.range drawActive:active];
}
}
- (BOOL)resignFirstResponder
{
[self rehighlightSelectedRangesAsActive:NO];
self.firstResponder = NO;
return YES;
}
- (BOOL)becomeFirstResponder
{
[self rehighlightSelectedRangesAsActive:YES];
self.firstResponder = YES;
return YES;
}
#pragma mark - Selection
- (void)setSelectionRangeForTextView:(NSTextView *)textView withRange:(NSRange)range
{
if (range.location == NSNotFound || NSMaxRange(range) == 0) {
[textView ind_deselectHighlightedText];
[self.currentSession removeSelectionRangeForTextView:textView];
} else {
INDTextViewSelectionRange *selRange = [[INDTextViewSelectionRange alloc] initWithTextView:textView selectedRange:range];
[self.currentSession addSelectionRange:selRange];
[textView ind_highlightSelectedTextInRange:range drawActive:YES];
}
}
- (void)processCompleteSelectionsForTargetTextView:(NSTextView *)textView affinity:(NSSelectionAffinity)affinity
{
if (self.currentSession == nil) return;
INDTextViewMetadata *meta = self.textViews[self.currentSession.textViewIdentifier];
NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
NSUInteger end = [self.sortedTextViews indexOfObject:textView];
if (start == NSNotFound || end == NSNotFound) return;
NSRange subrange = NSMakeRange(NSNotFound, 0);
BOOL select = NO;
NSUInteger count = self.sortedTextViews.count;
if (end > start) {
if (affinity == NSSelectionAffinityDownstream) {
subrange = NSMakeRange(start, end - start);
select = YES;
} else if (count > end + 1) {
subrange = NSMakeRange(end + 1, count - end - 1);
}
} else if (end < start) {
if (affinity == NSSelectionAffinityUpstream) {
subrange = NSMakeRange(end + 1, start - end);
select = YES;
} else {
subrange = NSMakeRange(0, end);
}
}
NSArray *subarray = nil;
if (subrange.location == NSNotFound) {
NSMutableOrderedSet *views = [self.sortedTextViews mutableCopy];
[views removeObject:textView];
subarray = views.array;
} else {
subarray = [self.sortedTextViews.array subarrayWithRange:subrange];
}
for (NSTextView *tv in subarray) {
NSRange range;
if (select) {
NSRange currentRange = tv.ind_highlightedRange;
if (affinity == NSSelectionAffinityDownstream) {
range = NSMakeRange(currentRange.location, tv.string.length - currentRange.location);
} else {
range = NSMakeRange(0, NSMaxRange(currentRange) ?: tv.string.length);
}
} else {
range = NSMakeRange(0, 0);
}
[self setSelectionRangeForTextView:tv withRange:range];
}
}
- (void)endSession
{
for (INDTextViewMetadata *meta in self.textViews.allValues) {
[meta.textView ind_deselectHighlightedText];
}
self.currentSession = nil;
self.cachedAttributedText = nil;
}
#pragma mark - Text
- (NSAttributedString *)buildAttributedStringForCurrentSession
{
if (self.currentSession == nil) return nil;
NSDictionary *ranges = self.currentSession.selectionRanges;
NSMutableArray *keys = [ranges.allKeys mutableCopy];
NSComparator textViewComparator = self.textViewComparator;
[keys sortUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) {
INDTextViewMetadata *meta1 = self.textViews[obj1];
INDTextViewMetadata *meta2 = self.textViews[obj2];
return textViewComparator(meta1.textView, meta2.textView);
}];
NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
[string beginEditing];
[keys enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) {
INDTextViewSelectionRange *range = ranges[key];
INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
NSAttributedString *fragment = range.attributedText;
if (meta.transformationBlock != nil) {
fragment = meta.transformationBlock(fragment);
}
[string appendAttributedString:fragment];
if (string.length && idx != keys.count - 1) {
NSDictionary *attributes = [string attributesAtIndex:string.length - 1 effectiveRange:NULL];
NSAttributedString *newline = [[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
[string appendAttributedString:newline];
}
}];
[string endEditing];
return string;
}
#pragma mark - Registration
- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier
{
[self registerTextView:textView withUniqueIdentifier:identifier transformationBlock:nil];
}
- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier transformationBlock:(INDAttributedTextTransformationBlock)block
{
NSParameterAssert(identifier);
NSParameterAssert(textView);
[self unregisterTextView:textView];
textView.ind_uniqueIdentifier = identifier;
if (self.currentSession) {
INDTextViewSelectionRange *range = self.currentSession.selectionRanges[identifier];
if (range) {
[textView ind_highlightSelectedTextInRange:range.range drawActive:self.firstResponder];
}
}
self.textViews[identifier] = [[INDTextViewMetadata alloc] initWithTextView:textView transformationBlock:block];
[self.sortedTextViews addObject:textView];
[self sortTextViews];
}
- (NSComparator)textViewComparator
{
return ^NSComparisonResult(NSTextView *obj1, NSTextView *obj2) {
// Convert to window coordinates to normalize coordinate flipped-ness
NSRect frame1 = [obj1 convertRect:obj1.bounds toView:nil];
NSRect frame2 = [obj2 convertRect:obj2.bounds toView:nil];
CGFloat y1 = NSMinY(frame1);
CGFloat y2 = NSMinY(frame2);
if (y1 > y2) {
return NSOrderedAscending;
} else if (y1 < y2) {
return NSOrderedDescending;
} else {
return NSOrderedSame;
}
};
}
- (void)sortTextViews
{
[self.sortedTextViews sortUsingComparator:self.textViewComparator];
}
- (void)unregisterTextView:(NSTextView *)textView
{
if (textView.ind_uniqueIdentifier == nil) return;
[self.textViews removeObjectForKey:textView.ind_uniqueIdentifier];
[self.sortedTextViews removeObject:textView];
[self sortTextViews];
textView.ind_uniqueIdentifier = nil;
}
- (void)unregisterAllTextViews
{
[self.textViews removeAllObjects];
[self.sortedTextViews removeAllObjects];
}
@end