blob: c9ff4273252aaf2db6bcb09efc45b21518d40d90 [file] [log] [blame]
Alexandre Lision83180df2016-01-18 11:32:20 -05001//
2// INDSequentialTextSelectionManager.m
3// INDSequentialTextSelectionManager
4//
5// Created by Indragie Karunaratne on 2014-03-02.
6// Copyright (c) 2014 Indragie Karunaratne. All rights reserved.
7//
8
9#import "INDSequentialTextSelectionManager.h"
10#import <objc/runtime.h>
11
12static NSUInteger INDCharacterIndexForTextViewEvent(NSEvent *event, NSTextView *textView)
13{
14 NSView *contentView = event.window.contentView;
15 NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
16 NSPoint textPoint = [textView convertPoint:point fromView:contentView];
17 return [textView characterIndexForInsertionAtPoint:textPoint];
18}
19
20static NSRange INDForwardRangeForIndices(NSUInteger idx1, NSUInteger idx2) {
21 NSRange range;
22 if (idx2 >= idx1) {
23 range = NSMakeRange(idx1, idx2 - idx1);
24 } else if (idx2 < idx1) {
25 range = NSMakeRange(idx2, idx1 - idx2);
26 } else {
27 range = NSMakeRange(NSNotFound, 0);
28 }
29 return range;
30}
31
32static void * INDUniqueIdentifierKey = &INDUniqueIdentifierKey;
33
34@interface NSTextView (INDUniqueIdentifiers)
35@property (nonatomic, copy) NSString *ind_uniqueIdentifier;
36@end
37
38@implementation NSTextView (INDUniqueIdentifiers)
39
40- (void)setInd_uniqueIdentifier:(NSString *)ind_uniqueIdentifier
41{
42 objc_setAssociatedObject(self, INDUniqueIdentifierKey, ind_uniqueIdentifier, OBJC_ASSOCIATION_COPY_NONATOMIC);
43}
44
45- (NSString *)ind_uniqueIdentifier
46{
47 return objc_getAssociatedObject(self, INDUniqueIdentifierKey);
48}
49
50@end
51
52@interface INDAttributeRange : NSObject
53@property (nonatomic, copy, readonly) NSString *attribute;
54@property (nonatomic, strong, readonly) id value;
55@property (nonatomic, assign, readonly) NSRange range;
56- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range;
57@end
58
59@implementation INDAttributeRange
60
61- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range
62{
63 if ((self = [super init])) {
64 _attribute = [attribute copy];
65 _value = value;
66 _range = range;
67 }
68 return self;
69}
70
71@end
72
73static void * INDBackgroundColorRangesKey = &INDBackgroundColorRangesKey;
74static void * INDHighlightedRangeKey = &INDHighlightedRangeKey;
75
76#define IND_DISABLED_SELECTED_TEXT_BG_COLOR [NSColor colorWithDeviceRed:0.83 green:0.83 blue:0.83 alpha:1.0]
77
78@interface NSTextView (INDSelectionHighlight)
79@property (nonatomic, strong) NSArray *ind_backgroundColorRanges;
80@property (nonatomic, assign) NSRange ind_highlightedRange;
81@end
82
83@implementation NSTextView (INDSelectionHighlight)
84
85- (void)ind_highlightSelectedTextInRange:(NSRange)range drawActive:(BOOL)active
86{
87 if (self.ind_backgroundColorRanges == nil) {
88 [self ind_backupBackgroundColorState];
89 }
90 self.ind_highlightedRange = range;
91
92 NSColor *selectedColor = nil;
93 if (active) {
94 selectedColor = self.selectedTextAttributes[NSBackgroundColorAttributeName] ?: NSColor.selectedTextBackgroundColor;
95 } else {
96 selectedColor = IND_DISABLED_SELECTED_TEXT_BG_COLOR;
97 }
98 [self.textStorage beginEditing];
99 [self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.textStorage.length)];
100 [self.textStorage addAttribute:NSBackgroundColorAttributeName value:selectedColor range:range];
101 [self.textStorage endEditing];
102 [self setNeedsDisplay:YES];
103}
104
105- (void)ind_backupBackgroundColorState
106{
107 NSMutableArray *ranges = [NSMutableArray array];
108 NSString *attribute = NSBackgroundColorAttributeName;
109 [self.textStorage enumerateAttribute:attribute inRange:NSMakeRange(0, self.textStorage.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
110 if (value == nil) return;
111 INDAttributeRange *attrRange = [[INDAttributeRange alloc] initWithAttribute:attribute value:value range:range];
112 [ranges addObject:attrRange];
113 }];
114 self.ind_backgroundColorRanges = ranges;
115}
116
117- (void)ind_deselectHighlightedText
118{
119 [self.textStorage beginEditing];
120 [self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.string.length)];
121 NSArray *ranges = self.ind_backgroundColorRanges;
122 for (INDAttributeRange *range in ranges) {
123 [self.textStorage addAttribute:range.attribute value:range.value range:range.range];
124 }
125 [self.textStorage endEditing];
126 [self setNeedsDisplay:YES];
127
128 self.ind_backgroundColorRanges = nil;
129 self.ind_highlightedRange = NSMakeRange(0, 0);
130}
131
132- (void)setInd_backgroundColorRanges:(NSArray *)ind_backgroundColorRanges
133{
134 objc_setAssociatedObject(self, INDBackgroundColorRangesKey, ind_backgroundColorRanges, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
135}
136
137- (NSArray *)ind_backgroundColorRanges
138{
139 return objc_getAssociatedObject(self, INDBackgroundColorRangesKey);
140}
141
142- (void)setInd_highlightedRange:(NSRange)ind_highlightedRange
143{
144 objc_setAssociatedObject(self, INDHighlightedRangeKey, [NSValue valueWithRange:ind_highlightedRange], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
145}
146
147- (NSRange)ind_highlightedRange
148{
149 return [objc_getAssociatedObject(self, INDHighlightedRangeKey) rangeValue];
150}
151
152@end
153
154@interface INDTextViewSelectionRange : NSObject
155@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
156@property (nonatomic, assign, readonly) NSRange range;
157@property (nonatomic, copy, readonly) NSAttributedString *attributedText;
158- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range;
159@end
160
161@implementation INDTextViewSelectionRange
162
163- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range
164{
165 if ((self = [super init])) {
166 _textViewIdentifier = [textView.ind_uniqueIdentifier copy];
167 _range = range;
168 _attributedText = [[textView.attributedString attributedSubstringFromRange:range] copy];
169 }
170 return self;
171}
172
173@end
174
175@interface INDTextViewSelectionSession : NSObject
176@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
177@property (nonatomic, assign, readonly) NSUInteger characterIndex;
178@property (nonatomic, strong, readonly) NSDictionary *selectionRanges;
179@property (nonatomic, assign) NSPoint windowPoint;
180- (void)addSelectionRange:(INDTextViewSelectionRange *)range;
181- (void)removeSelectionRangeForTextView:(NSTextView *)textView;
182@end
183
184@implementation INDTextViewSelectionSession {
185 NSMutableDictionary *_selectionRanges;
186}
187@synthesize selectionRanges = _selectionRanges;
188
189- (id)initWithTextView:(NSTextView *)textView event:(NSEvent *)event
190{
191 if ((self = [super init])) {
192 _textViewIdentifier = [textView.ind_uniqueIdentifier copy];
193 _characterIndex = INDCharacterIndexForTextViewEvent(event, textView);
194 _selectionRanges = [NSMutableDictionary dictionary];
195 _windowPoint = event.locationInWindow;
196 }
197 return self;
198}
199
200- (void)addSelectionRange:(INDTextViewSelectionRange *)range
201{
202 NSParameterAssert(range.textViewIdentifier);
203 _selectionRanges[range.textViewIdentifier] = range;
204}
205
206- (void)removeSelectionRangeForTextView:(NSTextView *)textView
207{
208 NSParameterAssert(textView.ind_uniqueIdentifier);
209 [_selectionRanges removeObjectForKey:textView.ind_uniqueIdentifier];
210}
211
212@end
213
214@interface INDTextViewMetadata : NSObject
215@property (nonatomic, strong, readonly) NSTextView *textView;
216@property (nonatomic, copy, readonly) INDAttributedTextTransformationBlock transformationBlock;
217- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock;
218@end
219
220@implementation INDTextViewMetadata
221
222- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock
223{
224 if ((self = [super init])) {
225 _textView = textView;
226 _transformationBlock = [transformationBlock copy];
227 }
228 return self;
229}
230
231@end
232
233@interface INDSequentialTextSelectionManager ()
234@property (nonatomic, strong, readonly) NSMutableDictionary *textViews;
235@property (nonatomic, strong, readonly) NSMutableOrderedSet *sortedTextViews;
236@property (nonatomic, strong) INDTextViewSelectionSession *currentSession;
237@property (nonatomic, strong) NSAttributedString *cachedAttributedText;
238@property (nonatomic, strong) id eventMonitor;
239@property (nonatomic, assign, getter = isFirstResponder) BOOL firstResponder;
240@end
241
242@implementation INDSequentialTextSelectionManager
243
244#pragma mark - Iniialization
245
246- (id)init
247{
248 if ((self = [super init])) {
249 _textViews = [NSMutableDictionary dictionary];
250 _sortedTextViews = [NSMutableOrderedSet orderedSet];
251 _eventMonitor = [self addLocalEventMonitor];
252 }
253 return self;
254}
255
256#pragma mark - Cleanup
257
258- (void)dealloc
259{
260 [NSEvent removeMonitor:_eventMonitor];
261}
262
263#pragma mark - Events
264
265- (BOOL)handleLeftMouseDown:(NSEvent *)event
266{
267 // Allow for correct handling of double clicks on text views.
268 if (event.clickCount == 1) {
269 [self endSession];
270
271 NSTextView *textView = [self validTextViewForEvent:event];
272 // Ignore if the text view is not "owned" by this manager, or if it is being
273 // edited at the time of this event.
274 if (textView && textView.window.firstResponder != textView) {
275 self.currentSession = [[INDTextViewSelectionSession alloc] initWithTextView:textView event:event];
276 return YES;
277 }
278 }
279 return NO;
280}
281
282- (BOOL)handleLeftMouseUp:(NSEvent *)event
283{
284 if (self.currentSession == nil) return NO;
285
286 [event.window makeFirstResponder:self];
287 NSTextView *textView = [self validTextViewForEvent:event];
288 if (textView != nil) {
289 // Handle link clicks properly.
290 NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
291 if (index < textView.string.length) {
292 NSDictionary *attributes = [textView.attributedString attributesAtIndex:index effectiveRange:NULL];
293 id link = attributes[NSLinkAttributeName];
294
295 // From documentation, NSLinkAttributeName could be either an NSString * or NSURL *
296 if (link != nil) {
297 [textView clickedOnLink:link atIndex:index];
298 }
299 }
300 }
301 return YES;
302}
303
304- (BOOL)handleLeftMouseDragged:(NSEvent *)event
305{
306 if (self.currentSession == nil) return NO;
307
308 NSTextView *textView = [self validTextViewForEvent:event];
309 if (textView != nil) {
310 [textView.window makeFirstResponder:textView];
311 NSSelectionAffinity affinity = (event.locationInWindow.y < self.currentSession.windowPoint.y) ? NSSelectionAffinityDownstream : NSSelectionAffinityUpstream;
312 self.currentSession.windowPoint = event.locationInWindow;
313
314 NSUInteger current;
315 NSString *identifier = self.currentSession.textViewIdentifier;
316 if ([textView.ind_uniqueIdentifier isEqualTo:identifier]) {
317 current = self.currentSession.characterIndex;
318 } else {
319 INDTextViewMetadata *meta = self.textViews[identifier];
320 NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
321 NSUInteger end = [self.sortedTextViews indexOfObject:textView];
322 current = (end >= start) ? 0 : textView.string.length;
323 }
324 NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
325 NSRange range = INDForwardRangeForIndices(index, current);
326 [self setSelectionRangeForTextView:textView withRange:range];
327 [self processCompleteSelectionsForTargetTextView:textView affinity:affinity];
328 }
329 return YES;
330}
331
332- (BOOL)handleRightMouseDown:(NSEvent *)event
333{
334 if (self.currentSession == nil) return NO;
335
336 [event.window makeFirstResponder:self];
337 NSTextView *textView = [self validTextViewForEvent:event];
338 if (textView != nil) {
339 NSMenu *menu = [self menuForEvent:event];
340 [NSMenu popUpContextMenu:menu withEvent:event forView:textView];
341 }
342 return YES;
343}
344
345- (id)addLocalEventMonitor
346{
347 return [NSEvent addLocalMonitorForEventsMatchingMask:NSLeftMouseDownMask | NSLeftMouseDraggedMask | NSLeftMouseUpMask | NSRightMouseDownMask handler:^NSEvent *(NSEvent *event) {
348 switch (event.type) {
349 case NSLeftMouseDown:
350 return [self handleLeftMouseDown:event] ? nil : event;
351 case NSLeftMouseDragged:
352 return [self handleLeftMouseDragged:event] ? nil : event;
353 case NSLeftMouseUp:
354 return [self handleLeftMouseUp:event] ? nil : event;
355 case NSRightMouseDown:
356 return [self handleRightMouseDown:event] ? nil : event;
357 default:
358 return event;
359 }
360 }];
361}
362
363- (NSTextView *)validTextViewForEvent:(NSEvent *)event
364{
365 NSView *contentView = event.window.contentView;
366 NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
367 NSView *view = [contentView hitTest:point];
368 if ([view isKindOfClass:NSTextView.class]) {
369 NSTextView *textView = (NSTextView *)view;
370 NSString *identifier = textView.ind_uniqueIdentifier;
371 return (textView.isSelectable && identifier && self.textViews[identifier]) ? textView : nil;
372 }
373 return nil;
374}
375
376#pragma mark - NSResponder
377
378- (NSAttributedString *)cachedAttributedText
379{
380 if (_cachedAttributedText == nil) {
381 _cachedAttributedText = [self buildAttributedStringForCurrentSession];
382 }
383 return _cachedAttributedText;
384}
385
386- (void)copy:(id)sender
387{
388 NSPasteboard *pboard = NSPasteboard.generalPasteboard;
389 [pboard clearContents];
390 [pboard writeObjects:@[self.cachedAttributedText]];
391}
392
393- (NSMenu *)buildSharingMenu
394{
395 NSMenu *shareMenu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Share", nil)];
396 NSArray *services = [NSSharingService sharingServicesForItems:@[self.cachedAttributedText]];
397 for (NSSharingService *service in services) {
398 NSMenuItem *item = [shareMenu addItemWithTitle:service.title action:@selector(share:) keyEquivalent:@""];
399 item.target = self;
400 item.image = service.image;
401 item.representedObject = service;
402 }
403 return shareMenu;
404}
405
406- (NSMenu *)menuForEvent:(NSEvent *)theEvent
407{
408 NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Text Actions", nil)];
409 NSMenuItem *copy = [menu addItemWithTitle:NSLocalizedString(@"Copy", nil) action:@selector(copy:) keyEquivalent:@""];
410 copy.target = self;
411 [menu addItem:NSMenuItem.separatorItem];
412
413 NSMenuItem *share = [menu addItemWithTitle:NSLocalizedString(@"Share", nil) action:nil keyEquivalent:@""];
414 share.submenu = [self buildSharingMenu];
415
416 return menu;
417}
418
419- (void)share:(NSMenuItem *)item
420{
421 NSSharingService *service = item.representedObject;
422 [service performWithItems:@[self.cachedAttributedText]];
423}
424
425- (void)rehighlightSelectedRangesAsActive:(BOOL)active
426{
427 NSArray *ranges = self.currentSession.selectionRanges.allValues;
428 for (INDTextViewSelectionRange *range in ranges) {
429 INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
430 [meta.textView ind_highlightSelectedTextInRange:range.range drawActive:active];
431 }
432}
433
434- (BOOL)resignFirstResponder
435{
436 [self rehighlightSelectedRangesAsActive:NO];
437 self.firstResponder = NO;
438 return YES;
439}
440
441- (BOOL)becomeFirstResponder
442{
443 [self rehighlightSelectedRangesAsActive:YES];
444 self.firstResponder = YES;
445 return YES;
446}
447
448#pragma mark - Selection
449
450- (void)setSelectionRangeForTextView:(NSTextView *)textView withRange:(NSRange)range
451{
452 if (range.location == NSNotFound || NSMaxRange(range) == 0) {
453 [textView ind_deselectHighlightedText];
454 [self.currentSession removeSelectionRangeForTextView:textView];
455 } else {
456 INDTextViewSelectionRange *selRange = [[INDTextViewSelectionRange alloc] initWithTextView:textView selectedRange:range];
457 [self.currentSession addSelectionRange:selRange];
458 [textView ind_highlightSelectedTextInRange:range drawActive:YES];
459 }
460}
461
462- (void)processCompleteSelectionsForTargetTextView:(NSTextView *)textView affinity:(NSSelectionAffinity)affinity
463{
464 if (self.currentSession == nil) return;
465
466 INDTextViewMetadata *meta = self.textViews[self.currentSession.textViewIdentifier];
467 NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
468 NSUInteger end = [self.sortedTextViews indexOfObject:textView];
469 if (start == NSNotFound || end == NSNotFound) return;
470
471 NSRange subrange = NSMakeRange(NSNotFound, 0);
472 BOOL select = NO;
473 NSUInteger count = self.sortedTextViews.count;
474 if (end > start) {
475 if (affinity == NSSelectionAffinityDownstream) {
476 subrange = NSMakeRange(start, end - start);
477 select = YES;
478 } else if (count > end + 1) {
479 subrange = NSMakeRange(end + 1, count - end - 1);
480 }
481 } else if (end < start) {
482 if (affinity == NSSelectionAffinityUpstream) {
483 subrange = NSMakeRange(end + 1, start - end);
484 select = YES;
485 } else {
486 subrange = NSMakeRange(0, end);
487 }
488 }
489 NSArray *subarray = nil;
490 if (subrange.location == NSNotFound) {
491 NSMutableOrderedSet *views = [self.sortedTextViews mutableCopy];
492 [views removeObject:textView];
493 subarray = views.array;
494 } else {
495 subarray = [self.sortedTextViews.array subarrayWithRange:subrange];
496 }
497 for (NSTextView *tv in subarray) {
498 NSRange range;
499 if (select) {
500 NSRange currentRange = tv.ind_highlightedRange;
501 if (affinity == NSSelectionAffinityDownstream) {
502 range = NSMakeRange(currentRange.location, tv.string.length - currentRange.location);
503 } else {
504 range = NSMakeRange(0, NSMaxRange(currentRange) ?: tv.string.length);
505 }
506 } else {
507 range = NSMakeRange(0, 0);
508 }
509 [self setSelectionRangeForTextView:tv withRange:range];
510 }
511}
512
513- (void)endSession
514{
515 for (INDTextViewMetadata *meta in self.textViews.allValues) {
516 [meta.textView ind_deselectHighlightedText];
517 }
518 self.currentSession = nil;
519 self.cachedAttributedText = nil;
520}
521
522#pragma mark - Text
523
524- (NSAttributedString *)buildAttributedStringForCurrentSession
525{
526 if (self.currentSession == nil) return nil;
527
528 NSDictionary *ranges = self.currentSession.selectionRanges;
529 NSMutableArray *keys = [ranges.allKeys mutableCopy];
530 NSComparator textViewComparator = self.textViewComparator;
531 [keys sortUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) {
532 INDTextViewMetadata *meta1 = self.textViews[obj1];
533 INDTextViewMetadata *meta2 = self.textViews[obj2];
534 return textViewComparator(meta1.textView, meta2.textView);
535 }];
536 NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
537 [string beginEditing];
538 [keys enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) {
539 INDTextViewSelectionRange *range = ranges[key];
540 INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
541 NSAttributedString *fragment = range.attributedText;
542 if (meta.transformationBlock != nil) {
543 fragment = meta.transformationBlock(fragment);
544 }
545 [string appendAttributedString:fragment];
546 if (string.length && idx != keys.count - 1) {
547 NSDictionary *attributes = [string attributesAtIndex:string.length - 1 effectiveRange:NULL];
548 NSAttributedString *newline = [[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
549 [string appendAttributedString:newline];
550 }
551 }];
552 [string endEditing];
553 return string;
554}
555
556#pragma mark - Registration
557
558- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier
559{
560 [self registerTextView:textView withUniqueIdentifier:identifier transformationBlock:nil];
561}
562
563- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier transformationBlock:(INDAttributedTextTransformationBlock)block
564{
565 NSParameterAssert(identifier);
566 NSParameterAssert(textView);
567
568 [self unregisterTextView:textView];
569 textView.ind_uniqueIdentifier = identifier;
570 if (self.currentSession) {
571 INDTextViewSelectionRange *range = self.currentSession.selectionRanges[identifier];
572 if (range) {
573 [textView ind_highlightSelectedTextInRange:range.range drawActive:self.firstResponder];
574 }
575 }
576 self.textViews[identifier] = [[INDTextViewMetadata alloc] initWithTextView:textView transformationBlock:block];
577
578 [self.sortedTextViews addObject:textView];
579 [self sortTextViews];
580}
581
582- (NSComparator)textViewComparator
583{
584 return ^NSComparisonResult(NSTextView *obj1, NSTextView *obj2) {
585 // Convert to window coordinates to normalize coordinate flipped-ness
586 NSRect frame1 = [obj1 convertRect:obj1.bounds toView:nil];
587 NSRect frame2 = [obj2 convertRect:obj2.bounds toView:nil];
588
589 CGFloat y1 = NSMinY(frame1);
590 CGFloat y2 = NSMinY(frame2);
591
592 if (y1 > y2) {
593 return NSOrderedAscending;
594 } else if (y1 < y2) {
595 return NSOrderedDescending;
596 } else {
597 return NSOrderedSame;
598 }
599 };
600}
601
602- (void)sortTextViews
603{
604 [self.sortedTextViews sortUsingComparator:self.textViewComparator];
605}
606
607- (void)unregisterTextView:(NSTextView *)textView
608{
609 if (textView.ind_uniqueIdentifier == nil) return;
610 [self.textViews removeObjectForKey:textView.ind_uniqueIdentifier];
611 [self.sortedTextViews removeObject:textView];
612 [self sortTextViews];
613
614 textView.ind_uniqueIdentifier = nil;
615}
616
617- (void)unregisterAllTextViews
618{
619 [self.textViews removeAllObjects];
620 [self.sortedTextViews removeAllObjects];
621}
622
623@end