#import "MASShortcutView.h" #import "MASShortcutValidator.h" #import "MASLocalization.h" NSString *const MASShortcutBinding = @"shortcutValue"; static const CGFloat MASHintButtonWidth = 23; static const CGFloat MASButtonFontSize = 11; #pragma mark - @interface MASShortcutView () // Private accessors @property (nonatomic, getter = isHinting) BOOL hinting; @property (nonatomic, copy) NSString *shortcutPlaceholder; @property (nonatomic, assign) BOOL showsDeleteButton; @end #pragma mark - @implementation MASShortcutView { NSButtonCell *_shortcutCell; NSInteger _shortcutToolTipTag; NSInteger _hintToolTipTag; NSTrackingArea *_hintArea; BOOL _acceptsFirstResponder; } #pragma mark - + (Class)shortcutCellClass { return [NSButtonCell class]; } - (id)initWithFrame:(CGRect)frameRect { self = [super initWithFrame:frameRect]; if (self) { [self commonInit]; } return self; } - (id)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (self) { [self commonInit]; } return self; } - (void)commonInit { _shortcutCell = [[[self.class shortcutCellClass] alloc] init]; _shortcutCell.buttonType = NSPushOnPushOffButton; _shortcutCell.font = [[NSFontManager sharedFontManager] convertFont:_shortcutCell.font toSize:MASButtonFontSize]; _shortcutValidator = [MASShortcutValidator sharedValidator]; _enabled = YES; _showsDeleteButton = YES; _acceptsFirstResponder = NO; [self resetShortcutCellStyle]; } - (void)dealloc { [self activateEventMonitoring:NO]; [self activateResignObserver:NO]; } #pragma mark - Public accessors - (void)setEnabled:(BOOL)flag { if (_enabled != flag) { _enabled = flag; [self updateTrackingAreas]; self.recording = NO; [self setNeedsDisplay:YES]; } } - (void)setStyle:(MASShortcutViewStyle)newStyle { if (_style != newStyle) { _style = newStyle; [self resetShortcutCellStyle]; [self setNeedsDisplay:YES]; } } - (void)resetShortcutCellStyle { switch (_style) { case MASShortcutViewStyleDefault: { _shortcutCell.bezelStyle = NSRoundRectBezelStyle; break; } case MASShortcutViewStyleTexturedRect: { _shortcutCell.bezelStyle = NSTexturedRoundedBezelStyle; break; } case MASShortcutViewStyleRounded: { _shortcutCell.bezelStyle = NSRoundedBezelStyle; break; } case MASShortcutViewStyleFlat: { self.wantsLayer = YES; _shortcutCell.backgroundColor = [NSColor clearColor]; _shortcutCell.bordered = NO; break; } } } - (void)setRecording:(BOOL)flag { // Only one recorder can be active at the moment static MASShortcutView *currentRecorder = nil; if (flag && (currentRecorder != self)) { currentRecorder.recording = NO; currentRecorder = flag ? self : nil; } // Only enabled view supports recording if (flag && !self.enabled) return; // Only care about changes in state if (flag == _recording) return; _recording = flag; self.shortcutPlaceholder = nil; [self resetToolTips]; [self activateEventMonitoring:_recording]; [self activateResignObserver:_recording]; [self setNeedsDisplay:YES]; // Give VoiceOver users feedback on the result. Requires at least 10.9 to run. // We’re silencing the “tautological compare” warning here so that if someone // takes the naked source files and compiles them with -Wall, the following // NSAccessibilityPriorityKey comparison doesn’t cause a warning. See: // https://github.com/shpakovski/MASShortcut/issues/76 #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wtautological-compare" if (_recording == NO && (&NSAccessibilityPriorityKey != NULL)) { NSString* msg = _shortcutValue ? MASLocalizedString(@"Shortcut set", @"VoiceOver: Shortcut set") : MASLocalizedString(@"Shortcut cleared", @"VoiceOver: Shortcut cleared"); NSDictionary *announcementInfo = @{ NSAccessibilityAnnouncementKey : msg, NSAccessibilityPriorityKey : @(NSAccessibilityPriorityHigh), }; NSAccessibilityPostNotificationWithUserInfo(self, NSAccessibilityAnnouncementRequestedNotification, announcementInfo); } #pragma clang diagnostic pop } - (void)setShortcutValue:(MASShortcut *)shortcutValue { _shortcutValue = shortcutValue; [self resetToolTips]; [self setNeedsDisplay:YES]; [self propagateValue:shortcutValue forBinding:MASShortcutBinding]; if (self.shortcutValueChange) { self.shortcutValueChange(self); } } - (void)setShortcutPlaceholder:(NSString *)shortcutPlaceholder { _shortcutPlaceholder = shortcutPlaceholder.copy; [self setNeedsDisplay:YES]; } #pragma mark - Drawing - (BOOL)isFlipped { return YES; } - (void)drawInRect:(CGRect)frame withTitle:(NSString *)title alignment:(NSTextAlignment)alignment state:(NSInteger)state { _shortcutCell.title = title; _shortcutCell.alignment = alignment; _shortcutCell.state = state; _shortcutCell.enabled = self.enabled; switch (_style) { case MASShortcutViewStyleDefault: { [_shortcutCell drawWithFrame:frame inView:self]; break; } case MASShortcutViewStyleTexturedRect: { [_shortcutCell drawWithFrame:CGRectOffset(frame, 0.0, 1.0) inView:self]; break; } case MASShortcutViewStyleRounded: { [_shortcutCell drawWithFrame:CGRectOffset(frame, 0.0, 1.0) inView:self]; break; } case MASShortcutViewStyleFlat: { [_shortcutCell drawWithFrame:frame inView:self]; break; } } } - (void)drawRect:(CGRect)dirtyRect { if (self.shortcutValue) { NSString *buttonTitle; if (self.recording) { buttonTitle = NSStringFromMASKeyCode(kMASShortcutGlyphEscape); } else if (self.showsDeleteButton) { buttonTitle = NSStringFromMASKeyCode(kMASShortcutGlyphClear); } if (buttonTitle != nil) { [self drawInRect:self.bounds withTitle:buttonTitle alignment:NSRightTextAlignment state:NSOffState]; } CGRect shortcutRect; [self getShortcutRect:&shortcutRect hintRect:NULL]; NSString *title = (self.recording ? (_hinting ? MASLocalizedString(@"Use Old Shortcut", @"Cancel action button for non-empty shortcut in recording state") : (self.shortcutPlaceholder.length > 0 ? self.shortcutPlaceholder : MASLocalizedString(@"Type New Shortcut", @"Non-empty shortcut button in recording state"))) : _shortcutValue ? _shortcutValue.description : @""); [self drawInRect:shortcutRect withTitle:title alignment:NSCenterTextAlignment state:self.isRecording ? NSOnState : NSOffState]; } else { if (self.recording) { [self drawInRect:self.bounds withTitle:NSStringFromMASKeyCode(kMASShortcutGlyphEscape) alignment:NSRightTextAlignment state:NSOffState]; CGRect shortcutRect; [self getShortcutRect:&shortcutRect hintRect:NULL]; NSString *title = (_hinting ? MASLocalizedString(@"Cancel", @"Cancel action button in recording state") : (self.shortcutPlaceholder.length > 0 ? self.shortcutPlaceholder : MASLocalizedString(@"Type Shortcut", @"Empty shortcut button in recording state"))); [self drawInRect:shortcutRect withTitle:title alignment:NSCenterTextAlignment state:NSOnState]; } else { [self drawInRect:self.bounds withTitle:MASLocalizedString(@"Record Shortcut", @"Empty shortcut button in normal state") alignment:NSCenterTextAlignment state:NSOffState]; } } } #pragma mark - Mouse handling - (void)getShortcutRect:(CGRect *)shortcutRectRef hintRect:(CGRect *)hintRectRef { CGRect shortcutRect, hintRect; CGFloat hintButtonWidth = MASHintButtonWidth; switch (self.style) { case MASShortcutViewStyleTexturedRect: hintButtonWidth += 2.0; break; case MASShortcutViewStyleRounded: hintButtonWidth += 3.0; break; case MASShortcutViewStyleFlat: hintButtonWidth -= 8.0 - (_shortcutCell.font.pointSize - MASButtonFontSize); break; default: break; } CGRectDivide(self.bounds, &hintRect, &shortcutRect, hintButtonWidth, CGRectMaxXEdge); if (shortcutRectRef) *shortcutRectRef = shortcutRect; if (hintRectRef) *hintRectRef = hintRect; } - (BOOL)locationInShortcutRect:(CGPoint)location { CGRect shortcutRect; [self getShortcutRect:&shortcutRect hintRect:NULL]; return CGRectContainsPoint(shortcutRect, [self convertPoint:location fromView:nil]); } - (BOOL)locationInHintRect:(CGPoint)location { CGRect hintRect; [self getShortcutRect:NULL hintRect:&hintRect]; return CGRectContainsPoint(hintRect, [self convertPoint:location fromView:nil]); } - (void)mouseDown:(NSEvent *)event { if (self.enabled) { if (self.shortcutValue) { if (self.recording) { if ([self locationInHintRect:event.locationInWindow]) { self.recording = NO; } } else { if ([self locationInShortcutRect:event.locationInWindow]) { self.recording = YES; } else { self.shortcutValue = nil; } } } else { if (self.recording) { if ([self locationInHintRect:event.locationInWindow]) { self.recording = NO; } } else { self.recording = YES; } } } else { [super mouseDown:event]; } } #pragma mark - Handling mouse over - (void)updateTrackingAreas { [super updateTrackingAreas]; if (_hintArea) { [self removeTrackingArea:_hintArea]; _hintArea = nil; } // Forbid hinting if view is disabled if (!self.enabled) return; CGRect hintRect; [self getShortcutRect:NULL hintRect:&hintRect]; NSTrackingAreaOptions options = (NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | NSTrackingAssumeInside); _hintArea = [[NSTrackingArea alloc] initWithRect:hintRect options:options owner:self userInfo:nil]; [self addTrackingArea:_hintArea]; } - (void)setHinting:(BOOL)flag { if (_hinting != flag) { _hinting = flag; [self setNeedsDisplay:YES]; } } - (void)mouseEntered:(NSEvent *)event { self.hinting = YES; } - (void)mouseExited:(NSEvent *)event { self.hinting = NO; } void *kUserDataShortcut = &kUserDataShortcut; void *kUserDataHint = &kUserDataHint; - (void)resetToolTips { if (_shortcutToolTipTag) { [self removeToolTip:_shortcutToolTipTag], _shortcutToolTipTag = 0; } if (_hintToolTipTag) { [self removeToolTip:_hintToolTipTag], _hintToolTipTag = 0; } if ((self.shortcutValue == nil) || self.recording || !self.enabled) return; CGRect shortcutRect, hintRect; [self getShortcutRect:&shortcutRect hintRect:&hintRect]; _shortcutToolTipTag = [self addToolTipRect:shortcutRect owner:self userData:kUserDataShortcut]; _hintToolTipTag = [self addToolTipRect:hintRect owner:self userData:kUserDataHint]; } - (NSString *)view:(NSView *)view stringForToolTip:(NSToolTipTag)tag point:(CGPoint)point userData:(void *)data { if (data == kUserDataShortcut) { return MASLocalizedString(@"Click to record new shortcut", @"Tooltip for non-empty shortcut button"); } else if (data == kUserDataHint) { return MASLocalizedString(@"Delete shortcut", @"Tooltip for hint button near the non-empty shortcut"); } return nil; } #pragma mark - Event monitoring - (void)activateEventMonitoring:(BOOL)shouldActivate { static BOOL isActive = NO; if (isActive == shouldActivate) return; isActive = shouldActivate; static id eventMonitor = nil; if (shouldActivate) { __unsafe_unretained MASShortcutView *weakSelf = self; NSEventMask eventMask = (NSKeyDownMask | NSFlagsChangedMask); eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:eventMask handler:^(NSEvent *event) { // Create a shortcut from the event MASShortcut *shortcut = [MASShortcut shortcutWithEvent:event]; // Tab key must pass through. if (shortcut.keyCode == kVK_Tab){ return event; } // If the shortcut is a plain Delete or Backspace, clear the current shortcut and cancel recording if (!shortcut.modifierFlags && ((shortcut.keyCode == kVK_Delete) || (shortcut.keyCode == kVK_ForwardDelete))) { weakSelf.shortcutValue = nil; weakSelf.recording = NO; event = nil; } // If the shortcut is a plain Esc, cancel recording else if (!shortcut.modifierFlags && shortcut.keyCode == kVK_Escape) { weakSelf.recording = NO; event = nil; } // If the shortcut is Cmd-W or Cmd-Q, cancel recording and pass the event through else if ((shortcut.modifierFlags == NSCommandKeyMask) && (shortcut.keyCode == kVK_ANSI_W || shortcut.keyCode == kVK_ANSI_Q)) { weakSelf.recording = NO; } else { // Verify possible shortcut if (shortcut.keyCodeString.length > 0) { if ([_shortcutValidator isShortcutValid:shortcut]) { // Verify that shortcut is not used NSString *explanation = nil; if ([_shortcutValidator isShortcutAlreadyTakenBySystem:shortcut explanation:&explanation]) { // Prevent cancel of recording when Alert window is key [weakSelf activateResignObserver:NO]; [weakSelf activateEventMonitoring:NO]; NSString *format = MASLocalizedString(@"The key combination %@ cannot be used", @"Title for alert when shortcut is already used"); NSAlert* alert = [[NSAlert alloc]init]; alert.alertStyle = NSCriticalAlertStyle; alert.informativeText = explanation; alert.messageText = [NSString stringWithFormat:format, shortcut]; [alert addButtonWithTitle:MASLocalizedString(@"OK", @"Alert button when shortcut is already used")]; [alert runModal]; weakSelf.shortcutPlaceholder = nil; [weakSelf activateResignObserver:YES]; [weakSelf activateEventMonitoring:YES]; } else { weakSelf.shortcutValue = shortcut; weakSelf.recording = NO; } } else { // Key press with or without SHIFT is not valid input NSBeep(); } } else { // User is playing with modifier keys weakSelf.shortcutPlaceholder = shortcut.modifierFlagsString; } event = nil; } return event; }]; } else { [NSEvent removeMonitor:eventMonitor]; } } - (void)activateResignObserver:(BOOL)shouldActivate { static BOOL isActive = NO; if (isActive == shouldActivate) return; isActive = shouldActivate; static id observer = nil; NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; if (shouldActivate) { __unsafe_unretained MASShortcutView *weakSelf = self; observer = [notificationCenter addObserverForName:NSWindowDidResignKeyNotification object:self.window queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) { weakSelf.recording = NO; }]; } else { [notificationCenter removeObserver:observer]; } } #pragma mark Bindings // http://tomdalling.com/blog/cocoa/implementing-your-own-cocoa-bindings/ -(void) propagateValue:(id)value forBinding:(NSString*)binding { NSParameterAssert(binding != nil); //WARNING: bindingInfo contains NSNull, so it must be accounted for NSDictionary* bindingInfo = [self infoForBinding:binding]; if(!bindingInfo) return; //there is no binding //apply the value transformer, if one has been set NSDictionary* bindingOptions = [bindingInfo objectForKey:NSOptionsKey]; if(bindingOptions){ NSValueTransformer* transformer = [bindingOptions valueForKey:NSValueTransformerBindingOption]; if(!transformer || (id)transformer == [NSNull null]){ NSString* transformerName = [bindingOptions valueForKey:NSValueTransformerNameBindingOption]; if(transformerName && (id)transformerName != [NSNull null]){ transformer = [NSValueTransformer valueTransformerForName:transformerName]; } } if(transformer && (id)transformer != [NSNull null]){ if([[transformer class] allowsReverseTransformation]){ value = [transformer reverseTransformedValue:value]; } else { NSLog(@"WARNING: binding \"%@\" has value transformer, but it doesn't allow reverse transformations in %s", binding, __PRETTY_FUNCTION__); } } } id boundObject = [bindingInfo objectForKey:NSObservedObjectKey]; if(!boundObject || boundObject == [NSNull null]){ NSLog(@"ERROR: NSObservedObjectKey was nil for binding \"%@\" in %s", binding, __PRETTY_FUNCTION__); return; } NSString* boundKeyPath = [bindingInfo objectForKey:NSObservedKeyPathKey]; if(!boundKeyPath || (id)boundKeyPath == [NSNull null]){ NSLog(@"ERROR: NSObservedKeyPathKey was nil for binding \"%@\" in %s", binding, __PRETTY_FUNCTION__); return; } [boundObject setValue:value forKeyPath:boundKeyPath]; } #pragma mark - Accessibility - (BOOL)accessibilityIsIgnored { return NO; } - (NSString *)accessibilityHelp { return MASLocalizedString(@"To record a new shortcut, click this button, and then type the" @" new shortcut, or press delete to clear an existing shortcut.", @"VoiceOver shortcut help"); } - (NSString *)accessibilityLabel { NSString* title = _shortcutValue.description ?: @"Empty"; title = [title stringByAppendingFormat:@" %@", MASLocalizedString(@"keyboard shortcut", @"VoiceOver title")]; return title; } - (BOOL)accessibilityPerformPress { if (self.isRecording == NO) { self.recording = YES; return YES; } else { return NO; } } - (NSString *)accessibilityRole { return NSAccessibilityButtonRole; } - (BOOL)acceptsFirstResponder { return _acceptsFirstResponder; } - (void)setAcceptsFirstResponder:(BOOL)value { _acceptsFirstResponder = value; } - (BOOL)becomeFirstResponder { [self setNeedsDisplay:YES]; return [super becomeFirstResponder]; } - (BOOL)resignFirstResponder { [self setNeedsDisplay:YES]; return [super resignFirstResponder]; } - (void)drawFocusRingMask { [_shortcutCell drawFocusRingMaskWithFrame:[self bounds] inView:self]; } - (NSRect)focusRingMaskBounds { return [self bounds]; } @end