// // Copyright 2006 ShortcutRecorder Contributors // CC BY 4.0 // #import #import #import #import #import "SRRecorderControl.h" #import "SRShortcutAction.h" #import "SRKeyCodeTransformer.h" #import "SRModifierFlagsTransformer.h" #ifndef SR_DEBUG_DRAWING #define SR_DEBUG_DRAWING DEBUG && 0 #endif // SR_DEBUG_DRAWING const NSLayoutPriority SRRecorderControlLabelWidthPriority = NSLayoutPriorityDefaultHigh + 1; typedef NS_ENUM(NSUInteger, _SRRecorderControlButtonTag) { _SRRecorderControlInvalidButtonTag = -1, _SRRecorderControlCancelButtonTag = 0, _SRRecorderControlClearButtonTag = 1, _SRRecorderControlMainButtonTag = 2 }; static void *_SRStyleUserInterfaceLayoutDirectionObservingContext = &_SRStyleUserInterfaceLayoutDirectionObservingContext; static void *_SRStyleAppearanceObservingContext = &_SRStyleAppearanceObservingContext; static void *_SRStyleGuideObservingContext = &_SRStyleGuideObservingContext; #define _SRIfRespondsGet(obj, sel, default) [obj respondsToSelector:@selector(sel)] ? [obj sel] : (default) #define _SRIfRespondsGetProp(obj, sel, prop, default) [obj respondsToSelector:@selector(sel)] ? [[obj sel] prop] : (default) @implementation SRRecorderControl { BOOL _isCompatibilityModeEnabled; SRRecorderControlStyle *_style; NSInvocation *_notifyStyle; NSTrackingArea *_mainButtonTrackingArea; NSTrackingArea *_cancelButtonTrackingArea; NSTrackingArea *_clearButtonTrackingArea; _SRRecorderControlButtonTag _mouseTrackingButtonTag; NSToolTipTag _cancelButtonToolTipTag; NSToolTipTag _clearButtonToolTipTag; SRShortcut *_objectValue; // +NSEvent.modifierFlags may change across run loop calls // Extra care is needed to ensure that all methods will see the same flags. NSEventModifierFlags _lastSeenModifierFlags; BOOL _isLazilyInitializingStyle; // Controls intrinsic width of the label. NSLayoutConstraint *_labelWidthConstraint; } - (instancetype)initWithFrame:(NSRect)aFrameRect { self = [super initWithFrame:aFrameRect]; if (self) { [self initInternalState]; } return self; } - (void)initInternalState { self.enabled = YES; _allowsEmptyModifierFlags = NO; _drawsASCIIEquivalentOfShortcut = YES; _allowsEscapeToCancelRecording = YES; _allowsDeleteToClearShortcutAndEndRecording = YES; _allowedModifierFlags = SRCocoaModifierFlagsMask; _requiredModifierFlags = 0; _mouseTrackingButtonTag = _SRRecorderControlInvalidButtonTag; _cancelButtonToolTipTag = NSIntegerMax; _clearButtonToolTipTag = NSIntegerMax; _pausesGlobalShortcutMonitorWhileRecording = YES; _notifyStyle = [NSInvocation invocationWithMethodSignature:[SRRecorderControlStyle instanceMethodSignatureForSelector:@selector(recorderControlAppearanceDidChange:)]]; _notifyStyle.selector = @selector(recorderControlAppearanceDidChange:); [_notifyStyle retainArguments]; self.translatesAutoresizingMaskIntoConstraints = NO; [self setContentHuggingPriority:NSLayoutPriorityDefaultLow forOrientation:NSLayoutConstraintOrientationHorizontal]; [self setContentHuggingPriority:NSLayoutPriorityRequired forOrientation:NSLayoutConstraintOrientationVertical]; [self setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow forOrientation:NSLayoutConstraintOrientationHorizontal]; [self setContentCompressionResistancePriority:NSLayoutPriorityRequired forOrientation:NSLayoutConstraintOrientationVertical]; } - (void)dealloc { [NSNotificationCenter.defaultCenter removeObserver:self]; [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self]; [NSObject cancelPreviousPerformRequestsWithTarget:_notifyStyle]; if ([_style respondsToSelector:@selector(preferredComponents)]) { [_style removeObserver:self forKeyPath:@"preferredComponents.userInterfaceLayoutDirection" context:_SRStyleUserInterfaceLayoutDirectionObservingContext]; [_style removeObserver:self forKeyPath:@"preferredComponents.appearance" context:_SRStyleAppearanceObservingContext]; } [_style removeObserver:self forKeyPath:@"labelDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(backgroundDrawingGuide)]) [_style removeObserver:self forKeyPath:@"backgroundDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(cancelButtonDrawingGuide)]) [_style removeObserver:self forKeyPath:@"cancelButtonDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(clearButtonDrawingGuide)]) [_style removeObserver:self forKeyPath:@"clearButtonDrawingGuide.frame" context:_SRStyleGuideObservingContext]; } #pragma mark Properties @dynamic style; + (BOOL)automaticallyNotifiesObserversOfValue { return NO; } + (BOOL)automaticallyNotifiesObserversOfObjectValue { return NO; } + (BOOL)automaticallyNotifiesObserversOfStringValue { return NO; } + (BOOL)automaticallyNotifiesObserversOfAttributedStringValue { return NO; } + (NSSet *)keyPathsForValuesAffectingDictionaryValue { return [NSSet setWithObject:@"objectValue"]; } + (NSSet *)keyPathsForValuesAffectingStringValue { return [NSSet setWithObjects:@"objectValue", @"userInterfaceLayoutDirection", @"stringValueRespectsUserInterfaceLayoutDirection", nil]; } + (NSSet *)keyPathsForValuesAffectingAttributedStringValue { return [NSSet setWithObject:@"stringValue"]; } + (NSSet *)keyPathsForValuesAffectingUserInterfaceLayoutDirection { return [NSSet setWithObject:@"style"]; } - (void)setAllowedModifierFlags:(NSEventModifierFlags)newAllowedModifierFlags requiredModifierFlags:(NSEventModifierFlags)newRequiredModifierFlags allowsEmptyModifierFlags:(BOOL)newAllowsEmptyModifierFlags { newAllowedModifierFlags &= SRCocoaModifierFlagsMask; newRequiredModifierFlags &= SRCocoaModifierFlagsMask; if ((newAllowedModifierFlags & newRequiredModifierFlags) != newRequiredModifierFlags) { [NSException raise:NSInvalidArgumentException format:@"Required flags (%lu) MUST be allowed (%lu)", newAllowedModifierFlags, newRequiredModifierFlags]; } if (newAllowsEmptyModifierFlags && newRequiredModifierFlags != 0) { [NSException raise:NSInvalidArgumentException format:@"Empty modifier flags MUST be disallowed if required modifier flags are not empty."]; } if (newAllowedModifierFlags == _allowedModifierFlags && newRequiredModifierFlags == _requiredModifierFlags && newAllowsEmptyModifierFlags == _allowsEmptyModifierFlags) { return; } [self endRecording]; [self willChangeValueForKey:@"allowedModifierFlags"]; [self willChangeValueForKey:@"requiredModifierFlags"]; [self willChangeValueForKey:@"allowsEmptyModifierFlags"]; _allowedModifierFlags = newAllowedModifierFlags; _requiredModifierFlags = newRequiredModifierFlags; _allowsEmptyModifierFlags = newAllowsEmptyModifierFlags; [self didChangeValueForKey:@"allowedModifierFlags"]; [self didChangeValueForKey:@"requiredModifierFlags"]; [self didChangeValueForKey:@"allowsEmptyModifierFlags"]; } - (SRShortcut *)objectValue { if (_isCompatibilityModeEnabled) return (id)_objectValue.dictionaryRepresentation; else return _objectValue; } - (void)setObjectValue:(SRShortcut *)newObjectValue { if (newObjectValue == _objectValue || [newObjectValue isEqual:_objectValue]) return; [self willChangeValueForKey:@"objectValue"]; // Cocoa KVO and KVC frequently uses NSNull as object substituation of nil. // SRRecorderControl expects either nil or valid object value, it's convenient // to handle NSNull here and convert it into nil. if ((NSNull *)newObjectValue == NSNull.null) newObjectValue = nil; // Backward compatibility with Shortcut Recorder 2 else if ([newObjectValue isKindOfClass:NSDictionary.class] && _objectValue == nil) { NSLog(@"WARNING: Shortcut Recroder 2 compatibility mode enabled. Getters of objectValue and NSValueBinding will return an instance of NSDictionary."); _isCompatibilityModeEnabled = YES; newObjectValue = [SRShortcut shortcutWithDictionary:(NSDictionary *)newObjectValue]; } _objectValue = [newObjectValue copy]; [self didChangeValueForKey:@"objectValue"]; if (_isCompatibilityModeEnabled) [self propagateValue:_objectValue.dictionaryRepresentation forBinding:NSValueBinding]; else [self propagateValue:_objectValue forBinding:NSValueBinding]; if (!self.isRecording) { NSAccessibilityPostNotification(self, NSAccessibilityTitleChangedNotification); NSAccessibilityPostNotification(self, NSAccessibilityValueChangedNotification); [self setNeedsDisplayInRect:self.style.labelDrawingGuide.frame]; [self updateLabelConstraints]; } } - (NSDictionary *)dictionaryValue { return _objectValue.dictionaryRepresentation; } - (void)setDictionaryValue:(NSDictionary *)newDictionaryValue { self.objectValue = [SRShortcut shortcutWithDictionary:newDictionaryValue]; } - (id)value { return self.objectValue; } - (void)setValue:(id)newValue { if (NSIsControllerMarker(newValue)) [NSException raise:NSInternalInconsistencyException format:@"SRRecorderControl's NSValueBinding does not support controller value markers."]; self.objectValue = newValue; } - (SRRecorderControlStyle *)style { if (_style == nil) { _isLazilyInitializingStyle = YES; [self _setStyle:[self makeDefaultStyle]]; _isLazilyInitializingStyle = NO; } return _style; } - (void)setStyle:(SRRecorderControlStyle *)newStyle { if (newStyle == nil) newStyle = [self makeDefaultStyle]; else if ([newStyle isEqual:_style]) return; else newStyle = [newStyle copy]; [self _setStyle:newStyle]; } - (void)_setStyle:(SRRecorderControlStyle *)newStyle { [NSObject cancelPreviousPerformRequestsWithTarget:_notifyStyle]; if ([_style respondsToSelector:@selector(prepareForRemoval)]) [_style prepareForRemoval]; if ([_style respondsToSelector:@selector(preferredComponents)]) { [_style removeObserver:self forKeyPath:@"preferredComponents.userInterfaceLayoutDirection" context:_SRStyleUserInterfaceLayoutDirectionObservingContext]; [_style removeObserver:self forKeyPath:@"preferredComponents.appearance" context:_SRStyleAppearanceObservingContext]; } [_style removeObserver:self forKeyPath:@"labelDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(backgroundDrawingGuide)]) [_style removeObserver:self forKeyPath:@"backgroundDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(cancelButtonDrawingGuide)]) [_style removeObserver:self forKeyPath:@"cancelButtonDrawingGuide.frame" context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(clearButtonDrawingGuide)]) [_style removeObserver:self forKeyPath:@"clearButtonDrawingGuide.frame" context:_SRStyleGuideObservingContext]; _style = newStyle; if ([_style respondsToSelector:@selector(prepareForRecorderControl:)]) [_style prepareForRecorderControl:self]; _labelWidthConstraint = [_style.labelDrawingGuide.widthAnchor constraintEqualToConstant:0.0]; _labelWidthConstraint.priority = NSLayoutPriorityDefaultHigh + 1; _labelWidthConstraint.active = YES; if ([_style respondsToSelector:@selector(preferredComponents)]) { [_style addObserver:self forKeyPath:@"preferredComponents.userInterfaceLayoutDirection" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionPrior context:_SRStyleUserInterfaceLayoutDirectionObservingContext]; [_style addObserver:self forKeyPath:@"preferredComponents.appearance" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:_SRStyleAppearanceObservingContext]; } [_style addObserver:self forKeyPath:@"labelDrawingGuide.frame" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:_SRStyleGuideObservingContext]; if ([_style respondsToSelector:@selector(backgroundDrawingGuide)]) { [_style addObserver:self forKeyPath:@"backgroundDrawingGuide.frame" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:_SRStyleGuideObservingContext]; } if ([_style respondsToSelector:@selector(cancelButtonDrawingGuide)]) { [_style addObserver:self forKeyPath:@"cancelButtonDrawingGuide.frame" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:_SRStyleGuideObservingContext]; } if ([_style respondsToSelector:@selector(clearButtonDrawingGuide)]) { [_style addObserver:self forKeyPath:@"clearButtonDrawingGuide.frame" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:_SRStyleGuideObservingContext]; } if (self.isRecording) self.toolTip = _SRIfRespondsGet(self.style, recordingTooltip, SRLoc(@"Type shortcut")); else self.toolTip = _SRIfRespondsGet(self.style, normalTooltip, SRLoc(@"Click to record shortcut")); } - (NSBezierPath *)focusRingShape { NSRect focusRingFrame = _SRIfRespondsGetProp(self.style, backgroundDrawingGuide, frame, self.bounds); NSEdgeInsets alignmentInsets = self.alignmentRectInsets; NSEdgeInsets focusRingInsets = _SRIfRespondsGet(self.style, focusRingInsets, NSEdgeInsetsZero); NSSize cornerRadius = _SRIfRespondsGet(self.style, focusRingCornerRadius, NSZeroSize); focusRingFrame.origin.x += alignmentInsets.left + focusRingInsets.left; focusRingFrame.origin.y += alignmentInsets.top + focusRingInsets.top; focusRingFrame.size.width = fdim(focusRingFrame.size.width, alignmentInsets.left + alignmentInsets.right + focusRingInsets.left + focusRingInsets.right); focusRingFrame.size.height = fdim(focusRingFrame.size.height, alignmentInsets.top + alignmentInsets.bottom + focusRingInsets.top + focusRingInsets.bottom); return [NSBezierPath bezierPathWithRoundedRect:focusRingFrame xRadius:cornerRadius.width yRadius:cornerRadius.height]; } - (BOOL)isMainButtonHighlighted { if (_mouseTrackingButtonTag == _SRRecorderControlMainButtonTag) { NSPoint locationInView = [self convertPoint:self.window.mouseLocationOutsideOfEventStream fromView:nil]; return [self mouse:locationInView inRect:self.bounds]; } else return NO; } - (BOOL)isCancelButtonHighlighted { if (_mouseTrackingButtonTag == _SRRecorderControlCancelButtonTag) { NSPoint locationInView = [self convertPoint:self.window.mouseLocationOutsideOfEventStream fromView:nil]; NSRect cancelButtonFrame = _SRIfRespondsGetProp(self.style, cancelButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)); return [self mouse:locationInView inRect:cancelButtonFrame]; } else return NO; } - (BOOL)isClearButtonHighlighted { if (_mouseTrackingButtonTag == _SRRecorderControlClearButtonTag) { NSPoint locationInView = [self convertPoint:self.window.mouseLocationOutsideOfEventStream fromView:nil]; NSRect clearButtonFrame = _SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, clearButtonDrawingGuide, frame, NSZeroRect)); return [self mouse:locationInView inRect:clearButtonFrame]; } else return NO; } - (NSString *)drawingLabel { NSString *label = nil; if (self.isRecording) { if (_lastSeenModifierFlags) { __auto_type layoutDirection = self.stringValueRespectsUserInterfaceLayoutDirection ? self.userInterfaceLayoutDirection : NSUserInterfaceLayoutDirectionLeftToRight; label = [SRSymbolicModifierFlagsTransformer.sharedTransformer transformedValue:@(_lastSeenModifierFlags) layoutDirection:layoutDirection]; } else label = self.stringValue; if (!label.length) label = _SRIfRespondsGet(self.style, noValueRecordingLabel, SRLoc(@"Type shortcut")); } else { label = self.stringValue; if (!label.length) label = _SRIfRespondsGet(self.style, noValueNormalLabel, SRLoc(@"Record Shortcut")); } return label; } - (NSDictionary *)drawingLabelAttributes { if (self.enabled) { if (self.isRecording) return _SRIfRespondsGet(self.style, recordingLabelAttributes, nil); else return _SRIfRespondsGet(self.style, normalLabelAttributes, nil); } else return _SRIfRespondsGet(self.style, disabledLabelAttributes, nil); } #pragma mark Methods - (SRRecorderControlStyle *)makeDefaultStyle { return [SRRecorderControlStyle new]; } - (BOOL)beginRecording { __block BOOL result = NO; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" __auto_type DelegateShouldBeginRecording = ^{ if ([self.delegate respondsToSelector:@selector(recorderControlShouldBeginRecording:)]) return [self.delegate recorderControlShouldBeginRecording:self]; else if ([self.delegate respondsToSelector:@selector(shortcutRecorderShouldBeginRecording:)]) return [self.delegate shortcutRecorderShouldBeginRecording:self]; else return YES; }; #pragma clang diagnostic pop os_activity_initiate("-[SRRecorderControl beginRecording]", OS_ACTIVITY_FLAG_DEFAULT, ^{ if (!self.enabled) { result = NO; return; } if (self.isRecording) { result = YES; return; } BOOL shouldBeginRecording = DelegateShouldBeginRecording(); if (!shouldBeginRecording) { [self playAlert]; result = NO; return; } if (![self.window makeFirstResponder:self]) { [self playAlert]; result = NO; return; } self->_lastSeenModifierFlags = NSEvent.modifierFlags & self.allowedModifierFlags; [self willChangeValueForKey:@"isRecording"]; self->_isRecording = YES; [self didChangeValueForKey:@"isRecording"]; self.needsDisplay = YES; [self updateActiveConstraints]; [self updateLabelConstraints]; [self updateTrackingAreas]; self.toolTip = _SRIfRespondsGet(self.style, recordingTooltip, SRLoc(@"Type shortcut")); if (self.pausesGlobalShortcutMonitorWhileRecording) [SRGlobalShortcutMonitor.sharedMonitor pause]; NSDictionary *bindingInfo = [self infoForBinding:NSValueBinding]; if (bindingInfo) { id controller = bindingInfo[NSObservedObjectKey]; if ([controller respondsToSelector:@selector(objectDidBeginEditing:)]) [controller objectDidBeginEditing:(id) self]; } if ([self.delegate respondsToSelector:@selector(recorderControlDidBeginRecording:)]) [self.delegate recorderControlDidBeginRecording:self]; NSAccessibilityPostNotificationWithUserInfo(self, NSAccessibilityLayoutChangedNotification, @{NSAccessibilityUIElementsKey: @[self]}); NSAccessibilityPostNotification(self, NSAccessibilityTitleChangedNotification); result = YES; }); return result; } - (void)endRecording { if (!self.isRecording) return; os_activity_initiate("-[SRRecorderControl endRecording]", OS_ACTIVITY_FLAG_DEFAULT, ^{ [self endRecordingWithObjectValue:self->_objectValue]; }); } - (void)clearAndEndRecording { if (!self.isRecording) return; os_activity_initiate("-[SRRecorderControl clearAndEndRecording]", OS_ACTIVITY_FLAG_DEFAULT, ^{ [self endRecordingWithObjectValue:nil]; }); } - (void)endRecordingWithObjectValue:(SRShortcut *)anObjectValue { if (!self.isRecording) return; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" __auto_type DelegateDidEndRecording = ^{ if ([self.delegate respondsToSelector:@selector(recorderControlDidEndRecording:)]) [self.delegate recorderControlDidEndRecording:self]; else if ([self.delegate respondsToSelector:@selector(shortcutRecorderDidEndRecording:)]) [self.delegate shortcutRecorderDidEndRecording:self]; }; #pragma clang diagnostic pop os_activity_initiate("-[SRRecorderControl endRecordingWithObjectValue:]", OS_ACTIVITY_FLAG_IF_NONE_PRESENT, ^{ [self willChangeValueForKey:@"isRecording"]; self->_isRecording = NO; [self didChangeValueForKey:@"isRecording"]; self.objectValue = anObjectValue; self->_lastSeenModifierFlags = 0; self.needsDisplay = YES; [self updateActiveConstraints]; [self updateLabelConstraints]; [self updateTrackingAreas]; self.toolTip = _SRIfRespondsGet(self.style, normalTooltip, SRLoc(@"Click to record shortcut")); if (self.pausesGlobalShortcutMonitorWhileRecording) [SRGlobalShortcutMonitor.sharedMonitor resume]; NSDictionary *bindingInfo = [self infoForBinding:NSValueBinding]; if (bindingInfo) { id controller = bindingInfo[NSObservedObjectKey]; if ([controller respondsToSelector:@selector(objectDidEndEditing:)]) [controller objectDidEndEditing:(id)self]; } if (self.window.firstResponder == self && !self.canBecomeKeyView) [self.window makeFirstResponder:nil]; DelegateDidEndRecording(); [self sendAction:self.action to:self.target]; NSAccessibilityPostNotificationWithUserInfo(self, NSAccessibilityLayoutChangedNotification, @{NSAccessibilityUIElementsKey: @[self]}); NSAccessibilityPostNotification(self, NSAccessibilityTitleChangedNotification); }); } - (void)updateActiveConstraints { [NSLayoutConstraint activateConstraints:self.style.alwaysConstraints]; if (self.isRecording && _objectValue) { [NSLayoutConstraint deactivateConstraints:self.style.displayingConstraints]; [NSLayoutConstraint deactivateConstraints:self.style.recordingWithNoValueConstraints]; [NSLayoutConstraint activateConstraints:self.style.recordingWithValueConstraints]; } else if (self.isRecording) { [NSLayoutConstraint deactivateConstraints:self.style.displayingConstraints]; [NSLayoutConstraint deactivateConstraints:self.style.recordingWithValueConstraints]; [NSLayoutConstraint activateConstraints:self.style.recordingWithNoValueConstraints]; } else { [NSLayoutConstraint deactivateConstraints:self.style.recordingWithNoValueConstraints]; [NSLayoutConstraint deactivateConstraints:self.style.recordingWithValueConstraints]; [NSLayoutConstraint activateConstraints:self.style.displayingConstraints]; } } - (void)updateLabelConstraints { NSString *label = self.drawingLabel; NSDictionary *labelAttributes = self.drawingLabelAttributes; CGFloat labelWidth = NSWidth([label boundingRectWithSize:self.style.alignmentGuide.frame.size options:0 attributes:labelAttributes context:nil]); // Extra 2 points to avoid clipping of smoothing pixels. _labelWidthConstraint.constant = ceil(MAX(labelWidth, [labelAttributes[SRMinimalDrawableWidthAttributeName] doubleValue]) + 2.0); } - (void)drawBackground:(NSRect)aDirtyRect { NSRect backgroundFrame = [self centerScanRect:_SRIfRespondsGetProp(self.style, backgroundDrawingGuide, frame, self.bounds)]; if (NSIsEmptyRect(backgroundFrame) || ![self needsToDrawRect:backgroundFrame]) return; NSImage *left = nil; NSImage *center = nil; NSImage *right = nil; [NSGraphicsContext saveGraphicsState]; if (self.isRecording) { left = _SRIfRespondsGet(self.style, bezelRecordingLeft, nil); center = _SRIfRespondsGet(self.style, bezelRecordingCenter, nil); right = _SRIfRespondsGet(self.style, bezelRecordingRight, nil); } else { if (self.isMainButtonHighlighted) { left = _SRIfRespondsGet(self.style, bezelPressedLeft, nil); center = _SRIfRespondsGet(self.style, bezelPressedCenter, nil); right = _SRIfRespondsGet(self.style, bezelPressedRight, nil); } else if (self.enabled) { left = _SRIfRespondsGet(self.style, bezelNormalLeft, nil); center = _SRIfRespondsGet(self.style, bezelNormalCenter, nil); right = _SRIfRespondsGet(self.style, bezelNormalRight, nil); } else { left = _SRIfRespondsGet(self.style, bezelDisabledLeft, nil); center = _SRIfRespondsGet(self.style, bezelDisabledCenter, nil); right = _SRIfRespondsGet(self.style, bezelDisabledRight, nil); } } if (left && center && right) { os_trace_debug("#Developer drawing background using images"); NSDrawThreePartImage(backgroundFrame, left, center, right, NO, NSCompositeSourceOver, 1.0, self.isFlipped); } else { os_trace_debug("#Developer drawing background using color"); if (self.isOpaque) [NSColor.windowBackgroundColor setFill]; else [NSColor.clearColor setFill]; NSRectFill(NSIntersectionRect(backgroundFrame, aDirtyRect)); } [NSGraphicsContext restoreGraphicsState]; } - (void)drawInterior:(NSRect)aDirtyRect { [self drawLabel:aDirtyRect]; if (self.isRecording) { [self drawCancelButton:aDirtyRect]; if (_objectValue) [self drawClearButton:aDirtyRect]; } } - (void)drawLabel:(NSRect)aDirtyRect { NSRect labelFrame = self.style.labelDrawingGuide.frame; #if SR_DEBUG_DRAWING [NSColor.systemRedColor set]; NSRectFill(labelFrame); #endif if (NSIsEmptyRect(labelFrame) || ![self needsToDrawRect:labelFrame]) return; NSString *label = self.drawingLabel; NSDictionary *labelAttributes = self.drawingLabelAttributes; CGFloat baselineOffset = _SRIfRespondsGet(self.style, baselineLayoutOffsetFromBottom, self.style.baselineDrawingOffsetFromBottom); labelFrame.origin.y = NSMaxY(labelFrame) - baselineOffset; labelFrame = [self backingAlignedRect:labelFrame options:NSAlignRectFlipped | NSAlignMinXOutward | NSAlignMinYOutward | NSAlignMaxXInward | NSAlignMaxYInward]; [NSGraphicsContext saveGraphicsState]; #if SR_DEBUG_DRAWING [[NSColor.systemRedColor highlightWithLevel:0.5] set]; NSRectFill(labelFrame); #endif CGFloat minWidth = [labelAttributes[SRMinimalDrawableWidthAttributeName] doubleValue]; if (labelFrame.size.width >= minWidth) { if (!self.isOpaque && self.style.isLabelDrawingFrameOpaque) CGContextSetShouldSmoothFonts(NSGraphicsContext.currentContext.CGContext, true); [label drawWithRect:labelFrame options:0 attributes:labelAttributes context:nil]; } [NSGraphicsContext restoreGraphicsState]; } - (void)drawCancelButton:(NSRect)aDirtyRect { NSRect cancelButtonFrame = [self centerScanRect:_SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)]; #if SR_DEBUG_DRAWING [NSColor.systemBlueColor set]; NSRectFill([self centerScanRect:_SRIfRespondsGetProp(self.style, cancelButtonLayoutGuide, frame, NSZeroRect)]); [[NSColor.systemBlueColor highlightWithLevel:0.5] set]; NSRectFill(cancelButtonFrame); #endif if (NSIsEmptyRect(cancelButtonFrame) || ![self needsToDrawRect:cancelButtonFrame]) return; NSImage *image = self.isCancelButtonHighlighted ? _SRIfRespondsGet(self.style, cancelButtonPressed, nil) : _SRIfRespondsGet(self.style, cancelButton, nil); if (!image) return; [NSGraphicsContext saveGraphicsState]; [image drawInRect:cancelButtonFrame fromRect:NSZeroRect operation:NSCompositeSourceOver fraction:1.0 respectFlipped:YES hints:nil]; [NSGraphicsContext restoreGraphicsState]; } - (void)drawClearButton:(NSRect)aDirtyRect { NSRect clearButtonFrame = [self centerScanRect:_SRIfRespondsGetProp(self.style, clearButtonDrawingGuide, frame, NSZeroRect)]; #if SR_DEBUG_DRAWING [NSColor.systemGreenColor set]; NSRectFill([self centerScanRect:_SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, NSZeroRect)]); [[NSColor.systemGreenColor highlightWithLevel:0.5] set]; NSRectFill(clearButtonFrame); #endif if (NSIsEmptyRect(clearButtonFrame) || ![self needsToDrawRect:clearButtonFrame]) return; NSImage *image = self.isClearButtonHighlighted ? _SRIfRespondsGet(self.style, clearButtonPressed, nil) : _SRIfRespondsGet(self.style, clearButton, nil); if (!image) return; [NSGraphicsContext saveGraphicsState]; [image drawInRect:clearButtonFrame fromRect:NSZeroRect operation:NSCompositeSourceOver fraction:1.0 respectFlipped:YES hints:nil]; [NSGraphicsContext restoreGraphicsState]; } - (BOOL)areModifierFlagsValid:(NSEventModifierFlags)aModifierFlags forKeyCode:(SRKeyCode)aKeyCode { aModifierFlags &= SRCocoaModifierFlagsMask; __block BOOL allowModifierFlags = YES; os_activity_initiate("-[SRRecorderControl areModifierFlagsValid:forKeyCode:]", OS_ACTIVITY_FLAG_DEFAULT, ^{ if ((aModifierFlags & self.requiredModifierFlags) != self.requiredModifierFlags) allowModifierFlags = NO; else allowModifierFlags = [self areModifierFlagsAllowed:aModifierFlags forKeyCode:aKeyCode]; }); return allowModifierFlags; } - (BOOL)areModifierFlagsAllowed:(NSEventModifierFlags)aModifierFlags forKeyCode:(SRKeyCode)aKeyCode { aModifierFlags &= SRCocoaModifierFlagsMask; __block BOOL allowModifierFlags = YES; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" __auto_type DelegateShouldUnconditionallyAllowModifierFlags = ^{ if ([self.delegate respondsToSelector:@selector(recorderControl:shouldUnconditionallyAllowModifierFlags:forKeyCode:)]) { return [self.delegate recorderControl:self shouldUnconditionallyAllowModifierFlags:aModifierFlags forKeyCode:aKeyCode]; } else if ([self.delegate respondsToSelector:@selector(shortcutRecorder:shouldUnconditionallyAllowModifierFlags:forKeyCode:)]) { return [self.delegate shortcutRecorder:self shouldUnconditionallyAllowModifierFlags:aModifierFlags forKeyCode:aKeyCode]; } else return NO; }; #pragma clang diagnostic pop os_activity_initiate("-[SRRecorderControl areModifierFlagsAllowed:forKeyCode:]", OS_ACTIVITY_FLAG_IF_NONE_PRESENT, ^{ if ((aModifierFlags == 0 && !self.allowsEmptyModifierFlags) || ((aModifierFlags & self.allowedModifierFlags) != aModifierFlags)) { allowModifierFlags = DelegateShouldUnconditionallyAllowModifierFlags(); } }); return allowModifierFlags; } - (void)playAlert { NSBeep(); } - (void)propagateValue:(id)aValue forBinding:(NSString *)aBinding { NSParameterAssert(aBinding != nil); NSDictionary* bindingInfo = [self infoForBinding:aBinding]; if(!bindingInfo || (id)bindingInfo == NSNull.null) return; NSObject *boundObject = bindingInfo[NSObservedObjectKey]; if(!boundObject || (id)boundObject == NSNull.null) [NSException raise:NSInternalInconsistencyException format:@"NSObservedObjectKey MUST NOT be nil for binding \"%@\"", aBinding]; NSString* boundKeyPath = bindingInfo[NSObservedKeyPathKey]; if(!boundKeyPath || (id)boundKeyPath == NSNull.null) [NSException raise:NSInternalInconsistencyException format:@"NSObservedKeyPathKey MUST NOT be nil for binding \"%@\"", aBinding]; NSDictionary* bindingOptions = bindingInfo[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]) aValue = [transformer reverseTransformedValue:aValue]; else NSLog(@"WARNING: binding \"%@\" has value transformer, but it doesn't allow reverse transformations in %s", aBinding, __PRETTY_FUNCTION__); } } if (!_isCompatibilityModeEnabled && ([boundObject isKindOfClass:NSUserDefaults.class] || [boundObject isKindOfClass:NSUserDefaultsController.class]) && [aValue isKindOfClass:SRShortcut.class]) { os_trace_error("#Error The control is bound to NSUserDefaults but is not transformed into an allowed CFPreferences value"); NSLog(@"WARNING: Shortcut Recroder 2 compatibility mode enabled. Getters of objectValue and NSValueBinding will return an instance of NSDictionary."); _isCompatibilityModeEnabled = YES; aValue = [aValue dictionaryRepresentation]; } [boundObject setValue:aValue forKeyPath:boundKeyPath]; } - (void)controlTintDidChange:(NSNotification *)aNotification { [self scheduleControlViewAppearanceDidChange:aNotification]; } - (void)accessibilityDisplayOptionsDidChange:(NSNotification *)aNotification { [self scheduleControlViewAppearanceDidChange:aNotification]; } - (void)scheduleControlViewAppearanceDidChange:(nullable id)aReason { if (_notifyStyle == nil || ![_style respondsToSelector:@selector(recorderControlAppearanceDidChange:)]) // recorderControlAppearanceDidChange: is called whenever _style is created. return; [NSObject cancelPreviousPerformRequestsWithTarget:_notifyStyle]; [_notifyStyle setArgument:&aReason atIndex:2]; [_notifyStyle performSelector:@selector(invokeWithTarget:) withObject:_style afterDelay:0.0 inModes:@[NSRunLoopCommonModes]]; } - (BOOL)canCaptureKeyEvent { if (!self.enabled) { os_trace_debug("The control is disabled"); return NO; } else if (self.window.firstResponder != self) { os_trace_debug("The control is not the first responder"); return NO; } else if (self->_mouseTrackingButtonTag != _SRRecorderControlInvalidButtonTag) { os_trace_debug("The control is tracking %lu", self->_mouseTrackingButtonTag); return NO; } else return YES; } - (BOOL)canEndRecordingWithObjectValue:(nullable SRShortcut *)aShortcut { __block BOOL result = NO; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" __auto_type DelegateCanRecordShortcut = ^(SRShortcut *aShortcut){ if ([self.delegate respondsToSelector:@selector(recorderControl:canRecordShortcut:)]) return [self.delegate recorderControl:self canRecordShortcut:aShortcut]; else if ([self.delegate respondsToSelector:@selector(shortcutRecorder:canRecordShortcut:)]) return [self.delegate shortcutRecorder:self canRecordShortcut:aShortcut.dictionaryRepresentation]; else if ([self.delegate respondsToSelector:@selector(control:isValidObject:)]) return [self.delegate control:self isValidObject:aShortcut]; else return YES; }; #pragma clang diagnostic pop os_activity_initiate("-[SRRecorderControl canEndRecordingWithObjectValue:]", OS_ACTIVITY_FLAG_DEFAULT, ^{ if ([self areModifierFlagsValid:aShortcut.modifierFlags forKeyCode:aShortcut.keyCode]) { if (DelegateCanRecordShortcut(aShortcut)) { os_trace_debug("Valid and accepted shortcut"); result = YES; } else { os_trace_debug("Delegate rejected"); result = NO; } } else { os_trace_debug("Modifier flags %lu rejected", aShortcut.modifierFlags); result = NO; } }); return result; } #pragma mark NSAccessibility - (BOOL)isAccessibilityElement { return YES; } - (BOOL)isAccessibilityEnabled { return self.isEnabled; } - (NSString *)accessibilityLabel { if (self.isRecording) { return [SRLiteralModifierFlagsTransformer.sharedTransformer transformedValue:@(_lastSeenModifierFlags) layoutDirection:NSUserInterfaceLayoutDirectionLeftToRight]; } else return super.accessibilityLabel; } - (id)accessibilityValue { if (self.isRecording) return super.accessibilityValue; else { if (!_objectValue) return SRLoc(@"Empty"); NSString *flags = [SRLiteralModifierFlagsTransformer.sharedTransformer transformedValue:@(_objectValue.modifierFlags) layoutDirection:NSUserInterfaceLayoutDirectionLeftToRight]; SRKeyCodeTransformer *transformer = nil; if (self.drawsASCIIEquivalentOfShortcut) transformer = SRASCIILiteralKeyCodeTransformer.sharedTransformer; else transformer = SRLiteralKeyCodeTransformer.sharedTransformer; NSString *code = [transformer transformedValue:@(_objectValue.keyCode)]; if (!code) code = [NSString stringWithFormat:@"%hu", _objectValue.keyCode]; if (flags.length > 0) return [NSString stringWithFormat:@"%@-%@", flags, code]; else return [NSString stringWithFormat:@"%@", code]; } } - (NSString *)accessibilityHelp { return nil; } - (NSAccessibilityRole)accessibilityRole { return NSAccessibilityButtonRole; } - (NSString *)accessibilityRoleDescription { if (self.isRecording) return SRLoc(@"Type shortcut").localizedLowercaseString; else return SRLoc(@"Shortcut").localizedLowercaseString; } - (id)accessibilityHitTest:(NSPoint)aPoint { // NSControl's implementation relies on its cell which is nil for SRRecorderControl. return self; } - (BOOL)accessibilityPerformPress { return [self beginRecording]; } - (BOOL)accessibilityPerformCancel { if (self.isRecording) { [self endRecording]; return YES; } else return NO; } - (BOOL)accessibilityPerformDelete { if (self.isRecording && _objectValue) { [self clearAndEndRecording]; return YES; } else return NO; } #pragma mark NSEditor - (BOOL)commitEditing { // Shortcuts recording is atomic (either all or nothing) and there are no pending changes. [self discardEditing]; return YES; } - (void)commitEditingWithDelegate:(id)aDelegate didCommitSelector:(SEL)aDidCommitSelector contextInfo:(void *)aContextInfo { BOOL isEditingCommited = [self commitEditing]; // See AppKit's __NSSendCommitEditingSelector NSInvocation *i = [NSInvocation invocationWithMethodSignature:[aDelegate methodSignatureForSelector:aDidCommitSelector]]; [i setSelector:aDidCommitSelector]; [i setArgument:(void*)&self atIndex:2]; [i setArgument:&isEditingCommited atIndex:3]; [i setArgument:&aContextInfo atIndex:4]; [i retainArguments]; [i performSelector:@selector(invokeWithTarget:) withObject:aDelegate afterDelay:0 inModes:@[NSRunLoopCommonModes]]; } - (BOOL)commitEditingAndReturnError:(NSError * __autoreleasing *)outError { return [self commitEditing]; } - (void)discardEditing { [self endRecording]; } #pragma mark NSNibLoading - (void)prepareForInterfaceBuilder { [super prepareForInterfaceBuilder]; self.objectValue = [SRShortcut shortcutWithCode:0 modifierFlags:NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagShift | NSEventModifierFlagCommand characters:@"" charactersIgnoringModifiers:@"a"]; } #pragma mark NSViewToolTipOwner - (NSString *)view:(NSView *)aView stringForToolTip:(NSToolTipTag)aTag point:(NSPoint)aPoint userData:(void *)aData { if (aTag == _cancelButtonToolTipTag) return _SRIfRespondsGet(self.style, cancelButtonTooltip, _objectValue != nil ? SRLoc(@"Use old shortcut") : SRLoc(@"Cancel recording")); else if (aTag == _clearButtonToolTipTag) return _SRIfRespondsGet(self.style, clearButtonTooltip, SRLoc(@"Delete current shortcut")); else return [super view:aView stringForToolTip:aTag point:aPoint userData:aData]; } #pragma mark NSCoding - (instancetype)initWithCoder:(NSCoder *)aCoder { // Since Xcode 6.x, user can configure xib to Prefer Coder. // In that case view will be instantiated with initWithCoder. // // awakeFromNib cannot be used to set up defaults for IBDesignable, // because at the time it's called, it's impossible to know whether properties // were set by a user in xib or they are compilation-time defaults. self = [super initWithCoder:aCoder]; if (self) { [self initInternalState]; } return self; } #pragma mark NSControl @dynamic enabled; @synthesize refusesFirstResponder = _refusesFirstResponder; @synthesize tag = _tag; + (Class)cellClass { return nil; } - (NSAttributedString *)attributedStringValue { return [[NSAttributedString alloc] initWithString:self.stringValue]; } - (void)setAttributedStringValue:(NSAttributedString *)newAttributedStringValue { [self setObjectValue:[SRShortcut shortcutWithKeyEquivalent:newAttributedStringValue.string]]; } - (NSString *)stringValue { if (!_objectValue) return SRLoc(@""); __auto_type layoutDirection = self.stringValueRespectsUserInterfaceLayoutDirection ? self.userInterfaceLayoutDirection : NSUserInterfaceLayoutDirectionLeftToRight; NSString *flags = [SRSymbolicModifierFlagsTransformer.sharedTransformer transformedValue:@(_objectValue.modifierFlags) layoutDirection:layoutDirection]; SRKeyCodeTransformer *transformer = nil; if (self.drawsASCIIEquivalentOfShortcut) transformer = SRASCIILiteralKeyCodeTransformer.sharedTransformer; else transformer = SRLiteralKeyCodeTransformer.sharedTransformer; NSString *code = [transformer transformedValue:@(_objectValue.keyCode) withImplicitModifierFlags:nil explicitModifierFlags:@(_objectValue.modifierFlags) layoutDirection:layoutDirection]; if (!code) code = [NSString stringWithFormat:@"<%hu>", _objectValue.keyCode]; if (layoutDirection == NSUserInterfaceLayoutDirectionRightToLeft) return [NSString stringWithFormat:@"%@%@", code, flags]; else return [NSString stringWithFormat:@"%@%@", flags, code]; } - (void)setStringValue:(NSString *)newStringValue { [self setObjectValue:[SRShortcut shortcutWithKeyEquivalent:newStringValue]]; } - (BOOL)isHighlighted { return self.isMainButtonHighlighted; } - (BOOL)abortEditing { [self endRecording]; return NO; } #pragma mark NSView + (BOOL)requiresConstraintBasedLayout { return YES; } - (BOOL)isOpaque { return self.style.isOpaque; } - (BOOL)allowsVibrancy { return self.style.allowsVibrancy; } - (BOOL)isFlipped { return YES; } - (void)setUserInterfaceLayoutDirection:(NSUserInterfaceLayoutDirection)newUserInterfaceLayoutDirection { NSNumber *currentValue = objc_getAssociatedObject(self, @selector(userInterfaceLayoutDirection)); if (currentValue && currentValue.integerValue == newUserInterfaceLayoutDirection) return; objc_setAssociatedObject(self, @selector(userInterfaceLayoutDirection), @(newUserInterfaceLayoutDirection), OBJC_ASSOCIATION_RETAIN); [self scheduleControlViewAppearanceDidChange:nil]; } - (NSUserInterfaceLayoutDirection)userInterfaceLayoutDirection { // NSView uses associated objects to track whether default value was overridden. // Here the lookup order is altered in the following way // 1. View's own value // 2. Style's value // 3. View's default value that falls back to NSWindow and then NSApp NSNumber *currentValue = objc_getAssociatedObject(self, @selector(userInterfaceLayoutDirection)); if (currentValue) return currentValue.integerValue; // If there is no style yet, return macOS's default value. if (!_isLazilyInitializingStyle && [self.style respondsToSelector:@selector(preferredComponents)]) { __auto_type layoutDirection = self.style.preferredComponents.layoutDirection; if (layoutDirection != SRRecorderControlStyleComponentsLayoutDirectionUnspecified) return SRRecorderControlStyleComponentsLayoutDirectionToSystem(layoutDirection); } return super.userInterfaceLayoutDirection; } - (void)drawRect:(NSRect)aDirtyRect { [self drawBackground:aDirtyRect]; [self drawInterior:aDirtyRect]; } - (void)drawFocusRingMask { if (self.enabled && self.window.firstResponder == self) [self.focusRingShape fill]; } - (NSRect)focusRingMaskBounds { if (self.enabled && self.window.firstResponder == self) return self.focusRingShape.bounds; else return NSZeroRect; } - (NSEdgeInsets)alignmentRectInsets { return self.style.alignmentRectInsets; } - (NSSize)intrinsicContentSize { return self.style.intrinsicContentSize; } - (CGFloat)baselineOffsetFromBottom { return _SRIfRespondsGet(self.style, baselineLayoutOffsetFromBottom, self.style.baselineDrawingOffsetFromBottom); } - (CGFloat)firstBaselineOffsetFromTop { return self.style.alignmentGuide.frame.size.height - self.baselineOffsetFromBottom; } - (void)updateTrackingAreas { static const NSTrackingAreaOptions TrackingOptions = NSTrackingMouseEnteredAndExited | NSTrackingActiveWhenFirstResponder | NSTrackingEnabledDuringMouseDrag; NSRect cancelButtonFrame = _SRIfRespondsGetProp(self.style, cancelButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)); NSRect clearButtonFrame = _SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, clearButtonDrawingGuide, frame, NSZeroRect)); if (_mainButtonTrackingArea) [self removeTrackingArea:_mainButtonTrackingArea]; _mainButtonTrackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:TrackingOptions owner:self userInfo:nil]; [self addTrackingArea:_mainButtonTrackingArea]; if (_cancelButtonTrackingArea) { [self removeTrackingArea:_cancelButtonTrackingArea]; _cancelButtonTrackingArea = nil; } if (_clearButtonTrackingArea) { [self removeTrackingArea:_clearButtonTrackingArea]; _clearButtonTrackingArea = nil; } if (_cancelButtonToolTipTag != NSIntegerMax) { [self removeToolTip:_cancelButtonToolTipTag]; _cancelButtonToolTipTag = NSIntegerMax; } if (_clearButtonToolTipTag != NSIntegerMax) { [self removeToolTip:_clearButtonToolTipTag]; _clearButtonToolTipTag = NSIntegerMax; } if (self.isRecording) { if (!NSIsEmptyRect(cancelButtonFrame)) { _cancelButtonTrackingArea = [[NSTrackingArea alloc] initWithRect:cancelButtonFrame options:TrackingOptions owner:self userInfo:nil]; [self addTrackingArea:_cancelButtonTrackingArea]; // Since this method is used to set up tracking rects of aux buttons, the rest of the code is aware // it should be called whenever geometry or apperance changes. Therefore it's a good place to set up tooltip rects. _cancelButtonToolTipTag = [self addToolTipRect:_cancelButtonTrackingArea.rect owner:self userData:NULL]; } if (_objectValue && !NSIsEmptyRect(clearButtonFrame)) { _clearButtonTrackingArea = [[NSTrackingArea alloc] initWithRect:clearButtonFrame options:TrackingOptions owner:self userInfo:nil]; [self addTrackingArea:_clearButtonTrackingArea]; _clearButtonToolTipTag = [self addToolTipRect:_clearButtonTrackingArea.rect owner:self userData:NULL]; } } [super updateTrackingAreas]; } - (void)updateConstraints { [self updateActiveConstraints]; [self updateLabelConstraints]; [super updateConstraints]; } - (void)prepareForReuse { [self endRecording]; [super prepareForReuse]; } - (void)viewWillMoveToWindow:(NSWindow *)aWindow { if (self.window) { [NSNotificationCenter.defaultCenter removeObserver:self name:NSWindowDidResignKeyNotification object:self.window]; [NSNotificationCenter.defaultCenter removeObserver:self name:NSControlTintDidChangeNotification object:NSApp]; [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self name:NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification object:nil]; } if (aWindow) { [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(endRecording) name:NSWindowDidResignKeyNotification object:aWindow]; [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(controlTintDidChange:) name:NSControlTintDidChangeNotification object:NSApp]; [NSWorkspace.sharedWorkspace.notificationCenter addObserver:self selector:@selector(accessibilityDisplayOptionsDidChange:) name:NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification object:nil]; } [super viewWillMoveToWindow:aWindow]; } - (void)viewDidChangeBackingProperties { [super viewDidChangeBackingProperties]; [self scheduleControlViewAppearanceDidChange:nil]; } - (void)viewDidChangeEffectiveAppearance { [super viewDidChangeEffectiveAppearance]; [self scheduleControlViewAppearanceDidChange:nil]; } #pragma mark NSResponder - (BOOL)acceptsFirstResponder { return self.enabled && !self.refusesFirstResponder; } - (BOOL)resignFirstResponder { [self endRecording]; _mouseTrackingButtonTag = _SRRecorderControlInvalidButtonTag; return [super resignFirstResponder]; } - (BOOL)acceptsFirstMouse:(NSEvent *)anEvent { return YES; } - (BOOL)canBecomeKeyView { // SRRecorderControl uses the button metaphor, but buttons cannot become key unless // Full Keyboard Access is enabled. Respect this. return super.canBecomeKeyView && NSApp.fullKeyboardAccessEnabled; } - (BOOL)needsPanelToBecomeKey { return YES; } - (void)mouseDown:(NSEvent *)anEvent { if (!self.enabled) { [super mouseDown:anEvent]; return; } NSPoint locationInView = [self convertPoint:anEvent.locationInWindow fromView:nil]; NSRect cancelButtonFrame = _SRIfRespondsGetProp(self.style, cancelButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)); NSRect clearButtonFrame = _SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, clearButtonDrawingGuide, frame, NSZeroRect)); if (self.isRecording) { if ([self mouse:locationInView inRect:cancelButtonFrame]) { _mouseTrackingButtonTag = _SRRecorderControlCancelButtonTag; [self setNeedsDisplayInRect:_SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)]; } else if ([self mouse:locationInView inRect:clearButtonFrame]) { _mouseTrackingButtonTag = _SRRecorderControlClearButtonTag; [self setNeedsDisplayInRect:_SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, NSZeroRect)]; } else [super mouseDown:anEvent]; } else if ([self mouse:locationInView inRect:self.bounds]) { _mouseTrackingButtonTag = _SRRecorderControlMainButtonTag; self.needsDisplay = YES; } else [super mouseDown:anEvent]; } - (void)mouseUp:(NSEvent *)anEvent { if (!self.enabled) { [super mouseUp:anEvent]; return; } NSRect cancelButtonFrame = _SRIfRespondsGetProp(self.style, cancelButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, cancelButtonDrawingGuide, frame, NSZeroRect)); NSRect clearButtonFrame = _SRIfRespondsGetProp(self.style, clearButtonLayoutGuide, frame, _SRIfRespondsGetProp(self.style, clearButtonDrawingGuide, frame, NSZeroRect)); if (_mouseTrackingButtonTag != _SRRecorderControlInvalidButtonTag) { if (!self.window.isKeyWindow) { // It's possible to receive this event after window resigned its key status // e.g. when shortcut brings new window and makes it key. self.needsDisplay = YES; } else { NSPoint locationInView = [self convertPoint:anEvent.locationInWindow fromView:nil]; if (_mouseTrackingButtonTag == _SRRecorderControlMainButtonTag && [self mouse:locationInView inRect:self.bounds]) { [self beginRecording]; } else if (_mouseTrackingButtonTag == _SRRecorderControlCancelButtonTag && [self mouse:locationInView inRect:cancelButtonFrame]) { [self endRecording]; } else if (_mouseTrackingButtonTag == _SRRecorderControlClearButtonTag && [self mouse:locationInView inRect:clearButtonFrame]) { [self clearAndEndRecording]; } } _mouseTrackingButtonTag = _SRRecorderControlInvalidButtonTag; } else [super mouseUp:anEvent]; } - (void)mouseEntered:(NSEvent *)anEvent { if (self.enabled) { if (_mouseTrackingButtonTag == _SRRecorderControlMainButtonTag && anEvent.trackingArea == _mainButtonTrackingArea) [self setNeedsDisplayInRect:self.style.backgroundDrawingGuide.frame]; else if (_mouseTrackingButtonTag == _SRRecorderControlCancelButtonTag && anEvent.trackingArea == _cancelButtonTrackingArea) [self setNeedsDisplayInRect:self.style.cancelButtonDrawingGuide.frame]; else if (_mouseTrackingButtonTag == _SRRecorderControlClearButtonTag && anEvent.trackingArea == _clearButtonTrackingArea) [self setNeedsDisplayInRect:self.style.clearButtonDrawingGuide.frame]; } [super mouseEntered:anEvent]; } - (void)mouseExited:(NSEvent *)anEvent { if (self.enabled) { if (_mouseTrackingButtonTag == _SRRecorderControlMainButtonTag && anEvent.trackingArea == _mainButtonTrackingArea) [self setNeedsDisplayInRect:self.style.backgroundDrawingGuide.frame]; else if (_mouseTrackingButtonTag == _SRRecorderControlCancelButtonTag && anEvent.trackingArea == _cancelButtonTrackingArea) [self setNeedsDisplayInRect:self.style.cancelButtonDrawingGuide.frame]; else if (_mouseTrackingButtonTag == _SRRecorderControlClearButtonTag && anEvent.trackingArea == _clearButtonTrackingArea) [self setNeedsDisplayInRect:self.style.clearButtonDrawingGuide.frame]; } [super mouseExited:anEvent]; } - (void)keyDown:(NSEvent *)anEvent { if (![self performKeyEquivalent:anEvent]) [super keyDown:anEvent]; } - (BOOL)performKeyEquivalent:(NSEvent *)anEvent { __block BOOL result = NO; os_activity_initiate("-[SRRecorderControl performKeyEquivalent:]", OS_ACTIVITY_FLAG_DEFAULT, ^{ if (![self canCaptureKeyEvent]) return; if (self.isRecording) { if (anEvent.keyCode == USHRT_MAX) { // This shouldn't really happen ever, but was rarely observed. // See https://github.com/Kentzo/ShortcutRecorder/issues/40 os_trace_debug("Invalid key code"); result = NO; } else if (self.allowsEscapeToCancelRecording && anEvent.keyCode == SRKeyCodeEscape && (anEvent.modifierFlags & SRCocoaModifierFlagsMask) == 0) { os_trace_debug("Cancel via Esc"); [self endRecording]; result = YES; } else if (self.allowsDeleteToClearShortcutAndEndRecording && (anEvent.keyCode == SRKeyCodeDelete || anEvent.keyCode == SRKeyCodeForwardDelete) && (anEvent.modifierFlags & SRCocoaModifierFlagsMask) == 0) { os_trace_debug("Clear via Delete"); [self clearAndEndRecording]; result = YES; } else { SRShortcut *newObjectValue = [SRShortcut shortcutWithEvent:anEvent]; if ([self canEndRecordingWithObjectValue:newObjectValue]) [self endRecordingWithObjectValue:newObjectValue]; else { // Do not end editing and allow the client to make another attempt. [self playAlert]; } result = YES; } } else if (anEvent.keyCode == SRKeyCodeSpace) { os_trace_debug("Begin recording via Space"); result = [self beginRecording]; } else result = NO; }); return result; } - (void)flagsChanged:(NSEvent *)anEvent { if (self.isRecording && [self canCaptureKeyEvent]) { __auto_type modifierFlags = anEvent.modifierFlags & SRCocoaModifierFlagsMask; if (self.allowsModifierFlagsOnlyShortcut) { __auto_type keyCode = anEvent.keyCode; __auto_type nextModifierFlags = _lastSeenModifierFlags; // Only XOR when flag is added. if ((modifierFlags & NSEventModifierFlagCommand) && (keyCode == kVK_Command || keyCode == kVK_RightCommand)) nextModifierFlags ^= NSEventModifierFlagCommand; else if ((modifierFlags & NSEventModifierFlagOption) && (keyCode == kVK_Option || keyCode == kVK_RightOption)) nextModifierFlags ^= NSEventModifierFlagOption; else if ((modifierFlags & NSEventModifierFlagShift) && (keyCode == kVK_Shift || keyCode == kVK_RightShift)) nextModifierFlags ^= NSEventModifierFlagShift; else if ((modifierFlags & NSEventModifierFlagControl) && (keyCode == kVK_Control || keyCode == kVK_RightControl)) nextModifierFlags ^= NSEventModifierFlagControl; else if (modifierFlags == 0 && _lastSeenModifierFlags != 0) { SRShortcut *newObjectValue = [SRShortcut shortcutWithCode:SRKeyCodeNone modifierFlags:_lastSeenModifierFlags characters:nil charactersIgnoringModifiers:nil]; if ([self canEndRecordingWithObjectValue:newObjectValue]) [self endRecordingWithObjectValue:newObjectValue]; } if (nextModifierFlags != _lastSeenModifierFlags && ![self areModifierFlagsAllowed:nextModifierFlags forKeyCode:SRKeyCodeNone]) [self playAlert]; else _lastSeenModifierFlags = nextModifierFlags; } else { if (![self areModifierFlagsAllowed:modifierFlags forKeyCode:SRKeyCodeNone]) [self playAlert]; else _lastSeenModifierFlags = modifierFlags; } [self setNeedsDisplayInRect:self.style.labelDrawingGuide.frame]; [self updateLabelConstraints]; } [super flagsChanged:anEvent]; } #pragma mark NSObject + (void)initialize { if (self == [SRRecorderControl class]) { [self exposeBinding:NSValueBinding]; } } - (Class)valueClassForBinding:(NSBindingName)aBinding { if ([aBinding isEqualToString:NSValueBinding]) return SRShortcut.class; else return [super valueClassForBinding:aBinding]; } - (NSArray *)optionDescriptionsForBinding:(NSBindingName)aBinding { if ([aBinding isEqualToString:NSValueBinding]) { NSAttributeDescription *valueTransformer = [NSAttributeDescription new]; valueTransformer.name = NSValueTransformerBindingOption; valueTransformer.attributeType = NSStringAttributeType; valueTransformer.defaultValue = @""; NSAttributeDescription *valueTransformerName = [NSAttributeDescription new]; valueTransformerName.name = NSValueTransformerNameBindingOption; valueTransformerName.attributeType = NSStringAttributeType; valueTransformerName.defaultValue = @""; return @[valueTransformer, valueTransformerName]; } else return [super optionDescriptionsForBinding:aBinding]; } - (void)observeValueForKeyPath:(NSString *)aKeyPath ofObject:(id)anObject change:(NSDictionary *)aChange context:(void *)aContext { if (aContext == _SRStyleUserInterfaceLayoutDirectionObservingContext) { if ([aChange objectForKey:NSKeyValueChangeNotificationIsPriorKey]) [self willChangeValueForKey:@"userInterfaceLayoutDirection"]; else [self didChangeValueForKey:@"userInterfaceLayoutDirection"]; } else if (aContext == _SRStyleAppearanceObservingContext) { __auto_type appearance = [aChange[NSKeyValueChangeNewKey] unsignedIntegerValue]; if (appearance != SRRecorderControlStyleComponentsAppearanceUnspecified) self.appearance = [NSAppearance appearanceNamed:SRRecorderControlStyleComponentsAppearanceToSystem(appearance)]; else self.appearance = nil; } else if (aContext == _SRStyleGuideObservingContext) { [self setNeedsDisplayInRect:[aChange[NSKeyValueChangeOldKey] rectValue]]; [self setNeedsDisplayInRect:[aChange[NSKeyValueChangeNewKey] rectValue]]; } else [super observeValueForKeyPath:aKeyPath ofObject:anObject change:aChange context:aContext]; } @end #undef _SRIfRespondsGet #undef _SRIfRespondsGetProp