diff options
Diffstat (limited to 'Framework')
| -rw-r--r-- | Framework/Info.plist | 24 | ||||
| -rw-r--r-- | Framework/MASDictionaryTransformer.h | 19 | ||||
| -rw-r--r-- | Framework/MASDictionaryTransformer.m | 51 | ||||
| -rw-r--r-- | Framework/MASDictionaryTransformerTests.m | 32 | ||||
| -rw-r--r-- | Framework/MASHotKey.h | 12 | ||||
| -rw-r--r-- | Framework/MASHotKey.m | 44 | ||||
| -rw-r--r-- | Framework/MASKeyCodes.h | 42 | ||||
| -rw-r--r-- | Framework/MASShortcut.h | 70 | ||||
| -rw-r--r-- | Framework/MASShortcut.m | 241 | ||||
| -rw-r--r-- | Framework/MASShortcutBinder.h | 67 | ||||
| -rw-r--r-- | Framework/MASShortcutBinder.m | 114 | ||||
| -rw-r--r-- | Framework/MASShortcutBinderTests.m | 98 | ||||
| -rw-r--r-- | Framework/MASShortcutMonitor.h | 27 | ||||
| -rw-r--r-- | Framework/MASShortcutMonitor.m | 101 | ||||
| -rw-r--r-- | Framework/MASShortcutTests.m | 26 | ||||
| -rw-r--r-- | Framework/MASShortcutValidator.h | 15 | ||||
| -rw-r--r-- | Framework/MASShortcutValidator.m | 111 | ||||
| -rw-r--r-- | Framework/MASShortcutView+Bindings.h | 25 | ||||
| -rw-r--r-- | Framework/MASShortcutView+Bindings.m | 50 | ||||
| -rw-r--r-- | Framework/MASShortcutView.h | 24 | ||||
| -rw-r--r-- | Framework/MASShortcutView.m | 511 | ||||
| -rw-r--r-- | Framework/Prefix.pch | 2 | ||||
| -rw-r--r-- | Framework/Shortcut.h | 7 | 
23 files changed, 1713 insertions, 0 deletions
diff --git a/Framework/Info.plist b/Framework/Info.plist new file mode 100644 index 0000000..91a62a8 --- /dev/null +++ b/Framework/Info.plist @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> +	<key>CFBundleDevelopmentRegion</key> +	<string>English</string> +	<key>CFBundleExecutable</key> +	<string>${EXECUTABLE_NAME}</string> +	<key>CFBundleIdentifier</key> +	<string>com.github.shpakovski.${PRODUCT_NAME:rfc1034identifier}</string> +	<key>CFBundleInfoDictionaryVersion</key> +	<string>6.0</string> +	<key>CFBundleName</key> +	<string>${PRODUCT_NAME}</string> +	<key>CFBundlePackageType</key> +	<string>FMWK</string> +	<key>CFBundleShortVersionString</key> +	<string>2.0.0</string> +	<key>CFBundleVersion</key> +	<string>2.0.0</string> +	<key>NSHumanReadableCopyright</key> +	<string>Copyright © 2014–2015 Vadim Shpakovski. All rights reserved.</string> +</dict> +</plist> diff --git a/Framework/MASDictionaryTransformer.h b/Framework/MASDictionaryTransformer.h new file mode 100644 index 0000000..eced1bb --- /dev/null +++ b/Framework/MASDictionaryTransformer.h @@ -0,0 +1,19 @@ +extern NSString *const MASDictionaryTransformerName; + +/** +    @brief Converts shortcuts for storage in user defaults. + +    User defaults can’t stored custom types directly, they have to +    be serialized to @p NSData or some other supported type like an +    @p NSDictionary. In Cocoa Bindings, the conversion can be done +    using value transformers like this one. + +    There’s a built-in transformer (@p NSKeyedUnarchiveFromDataTransformerName) +    that converts any @p NSCoding types to @p NSData, but with shortcuts +    it makes sense to use a dictionary instead – the defaults look better +    when inspected with the @p defaults command-line utility and the +    format is compatible with an older sortcut library called Shortcut +    Recorder. +*/ +@interface MASDictionaryTransformer : NSValueTransformer +@end diff --git a/Framework/MASDictionaryTransformer.m b/Framework/MASDictionaryTransformer.m new file mode 100644 index 0000000..9e4c82b --- /dev/null +++ b/Framework/MASDictionaryTransformer.m @@ -0,0 +1,51 @@ +#import "MASDictionaryTransformer.h" +#import "MASShortcut.h" + +NSString *const MASDictionaryTransformerName = @"MASDictionaryTransformer"; + +static NSString *const MASKeyCodeKey = @"keyCode"; +static NSString *const MASModifierFlagsKey = @"modifierFlags"; + +@implementation MASDictionaryTransformer + ++ (BOOL) allowsReverseTransformation +{ +    return YES; +} + +// Storing nil values as an empty dictionary lets us differ between +// “not available, use default value” and “explicitly set to none”. +// See http://stackoverflow.com/questions/5540760 for details. +- (NSDictionary*) reverseTransformedValue: (MASShortcut*) shortcut +{ +    if (shortcut == nil) { +        return [NSDictionary dictionary]; +    } else { +        return @{ +            MASKeyCodeKey: @([shortcut keyCode]), +            MASModifierFlagsKey: @([shortcut modifierFlags]) +        }; +    } +} + +- (MASShortcut*) transformedValue: (NSDictionary*) dictionary +{ +    // We have to be defensive here as the value may come from user defaults. +    if (![dictionary isKindOfClass:[NSDictionary class]]) { +        return nil; +    } + +    id keyCodeBox = [dictionary objectForKey:MASKeyCodeKey]; +    id modifierFlagsBox = [dictionary objectForKey:MASModifierFlagsKey]; + +    SEL integerValue = @selector(integerValue); +    if (![keyCodeBox respondsToSelector:integerValue] || ![modifierFlagsBox respondsToSelector:integerValue]) { +        return nil; +    } + +    return [MASShortcut +        shortcutWithKeyCode:[keyCodeBox integerValue] +        modifierFlags:[modifierFlagsBox integerValue]]; +} + +@end diff --git a/Framework/MASDictionaryTransformerTests.m b/Framework/MASDictionaryTransformerTests.m new file mode 100644 index 0000000..48e11f3 --- /dev/null +++ b/Framework/MASDictionaryTransformerTests.m @@ -0,0 +1,32 @@ +@interface MASDictionaryTransformerTests : XCTestCase +@end + +@implementation MASDictionaryTransformerTests + +- (void) testErrorHandling +{ +    MASDictionaryTransformer *transformer = [MASDictionaryTransformer new]; +    XCTAssertNil([transformer transformedValue:nil], +        @"Decoding a shortcut from a nil dictionary returns nil."); +    XCTAssertNil([transformer transformedValue:(id)@"foo"], +        @"Decoding a shortcut from a invalid-type dictionary returns nil."); +    XCTAssertNil([transformer transformedValue:@{}], +        @"Decoding a shortcut from an empty dictionary returns nil."); +    XCTAssertNil([transformer transformedValue:@{@"keyCode":@"foo"}], +        @"Decoding a shortcut from a wrong-typed dictionary returns nil."); +    XCTAssertNil([transformer transformedValue:@{@"keyCode":@1}], +        @"Decoding a shortcut from an incomplete dictionary returns nil."); +    XCTAssertNil([transformer transformedValue:@{@"modifierFlags":@1}], +        @"Decoding a shortcut from an incomplete dictionary returns nil."); +} + +- (void) testNilRepresentation +{ +    MASDictionaryTransformer *transformer = [MASDictionaryTransformer new]; +    XCTAssertEqualObjects([transformer reverseTransformedValue:nil], [NSDictionary dictionary], +        @"Store nil values as an empty dictionary."); +    XCTAssertNil([transformer transformedValue:[NSDictionary dictionary]], +        @"Load empty dictionary as nil."); +} + +@end diff --git a/Framework/MASHotKey.h b/Framework/MASHotKey.h new file mode 100644 index 0000000..1d267e4 --- /dev/null +++ b/Framework/MASHotKey.h @@ -0,0 +1,12 @@ +#import "MASShortcut.h" + +extern FourCharCode const MASHotKeySignature; + +@interface MASHotKey : NSObject + +@property(readonly) UInt32 carbonID; +@property(copy) dispatch_block_t action; + ++ (instancetype) registeredHotKeyWithShortcut: (MASShortcut*) shortcut; + +@end diff --git a/Framework/MASHotKey.m b/Framework/MASHotKey.m new file mode 100644 index 0000000..7886440 --- /dev/null +++ b/Framework/MASHotKey.m @@ -0,0 +1,44 @@ +#import "MASHotKey.h" + +FourCharCode const MASHotKeySignature = 'MASS'; + +@interface MASHotKey () +@property(assign) EventHotKeyRef hotKeyRef; +@property(assign) UInt32 carbonID; +@end + +@implementation MASHotKey + +- (instancetype) initWithShortcut: (MASShortcut*) shortcut +{ +    self = [super init]; + +    static UInt32 CarbonHotKeyID = 0; + +    _carbonID = ++CarbonHotKeyID; +    EventHotKeyID hotKeyID = { .signature = MASHotKeySignature, .id = _carbonID }; + +    OSStatus status = RegisterEventHotKey([shortcut carbonKeyCode], [shortcut carbonFlags], +        hotKeyID, GetEventDispatcherTarget(), kEventHotKeyExclusive, &_hotKeyRef); + +    if (status != noErr) { +        return nil; +    } + +    return self; +} + ++ (instancetype) registeredHotKeyWithShortcut: (MASShortcut*) shortcut +{ +    return [[self alloc] initWithShortcut:shortcut]; +} + +- (void) dealloc +{ +    if (_hotKeyRef) { +        UnregisterEventHotKey(_hotKeyRef); +        _hotKeyRef = NULL; +    } +} + +@end diff --git a/Framework/MASKeyCodes.h b/Framework/MASKeyCodes.h new file mode 100644 index 0000000..8c1ce06 --- /dev/null +++ b/Framework/MASKeyCodes.h @@ -0,0 +1,42 @@ +#import <Carbon/Carbon.h> + +// These glyphs are missed in Carbon.h +enum { +    kMASShortcutGlyphEject = 0x23CF, +    kMASShortcutGlyphClear = 0x2715, +    kMASShortcutGlyphDeleteLeft = 0x232B, +    kMASShortcutGlyphDeleteRight = 0x2326, +    kMASShortcutGlyphLeftArrow = 0x2190, +    kMASShortcutGlyphRightArrow = 0x2192, +    kMASShortcutGlyphUpArrow = 0x2191, +    kMASShortcutGlyphDownArrow = 0x2193, +    kMASShortcutGlyphEscape = 0x238B, +    kMASShortcutGlyphHelp = 0x003F, +    kMASShortcutGlyphPageDown = 0x21DF, +    kMASShortcutGlyphPageUp = 0x21DE, +    kMASShortcutGlyphTabRight = 0x21E5, +    kMASShortcutGlyphReturn = 0x2305, +    kMASShortcutGlyphReturnR2L = 0x21A9, +    kMASShortcutGlyphPadClear = 0x2327, +    kMASShortcutGlyphNorthwestArrow = 0x2196, +    kMASShortcutGlyphSoutheastArrow = 0x2198, +} MASShortcutGlyph; + +NS_INLINE NSString* NSStringFromMASKeyCode(unsigned short ch) +{ +    return [NSString stringWithFormat:@"%C", ch]; +} + +NS_INLINE NSUInteger MASPickCocoaModifiers(NSUInteger flags) +{ +    return (flags & (NSControlKeyMask | NSShiftKeyMask | NSAlternateKeyMask | NSCommandKeyMask)); +} + +NS_INLINE UInt32 MASCarbonModifiersFromCocoaModifiers(NSUInteger cocoaFlags) +{ +    return +          (cocoaFlags & NSCommandKeyMask ? cmdKey : 0) +        | (cocoaFlags & NSAlternateKeyMask ? optionKey : 0) +        | (cocoaFlags & NSControlKeyMask ? controlKey : 0) +        | (cocoaFlags & NSShiftKeyMask ? shiftKey : 0); +} diff --git a/Framework/MASShortcut.h b/Framework/MASShortcut.h new file mode 100644 index 0000000..3e1bedf --- /dev/null +++ b/Framework/MASShortcut.h @@ -0,0 +1,70 @@ +#import "MASKeyCodes.h" + +/** +    @brief A model class to hold a key combination. + +    This class just represents a combination of keys. It does not care if +    the combination is valid or can be used as a hotkey, it doesn’t watch +    the input system for the shortcut appearance, nor it does access user +    defaults. +*/ +@interface MASShortcut : NSObject <NSSecureCoding, NSCopying> + +/** +    @brief The virtual key code for the keyboard key. + +    @Hardware independent, same as in NSEvent. Events.h in the HIToolbox +    framework for a complete list, or Command-click this symbol: kVK_ANSI_A. +*/ +@property (nonatomic, readonly) NSUInteger keyCode; + +/** +    @brief Cocoa keyboard modifier flags. + +    Same as in NSEvent: NSCommandKeyMask, NSAlternateKeyMask, etc. +*/ +@property (nonatomic, readonly) NSUInteger modifierFlags; + +/** +    @brief Same as @p keyCode, just a different type. +*/ +@property (nonatomic, readonly) UInt32 carbonKeyCode; + +/** +    @brief Carbon modifier flags. + +    A bit sum of @p cmdKey, @p optionKey, etc. +*/ +@property (nonatomic, readonly) UInt32 carbonFlags; + +/** +    @brief A string representing the “key” part of a shortcut, like the “5” in “⌘5”. +*/ +@property (nonatomic, readonly) NSString *keyCodeString; + +/** +    @brief A key-code string used in key equivalent matching. + +    For precise meaning of “key equivalents” see the @p keyEquivalent +    property of @p NSMenuItem. Here the string is used to support shortcut +    validation (“is the shortcut already taken in this menu?”) and +    for display in @p NSMenu. +*/ +@property (nonatomic, readonly) NSString *keyCodeStringForKeyEquivalent; + +/** +    @brief A string representing the shortcut modifiers, like the “⌘” in “⌘5”. +*/ +@property (nonatomic, readonly) NSString *modifierFlagsString; + +- (instancetype)initWithKeyCode:(NSUInteger)code modifierFlags:(NSUInteger)flags; ++ (instancetype)shortcutWithKeyCode:(NSUInteger)code modifierFlags:(NSUInteger)flags; + +/** +    @brief Creates a new shortcut from an NSEvent object. + +    This is just a convenience initializer that reads the key code and modifiers from an NSEvent. +*/ ++ (instancetype)shortcutWithEvent:(NSEvent *)anEvent; + +@end diff --git a/Framework/MASShortcut.m b/Framework/MASShortcut.m new file mode 100644 index 0000000..e6fa63d --- /dev/null +++ b/Framework/MASShortcut.m @@ -0,0 +1,241 @@ +#import "MASShortcut.h" + +static NSString *const MASShortcutKeyCode = @"KeyCode"; +static NSString *const MASShortcutModifierFlags = @"ModifierFlags"; + +@implementation MASShortcut + +#pragma mark Initialization + +- (instancetype)initWithKeyCode:(NSUInteger)code modifierFlags:(NSUInteger)flags +{ +    self = [super init]; +    if (self) { +        _keyCode = code; +        _modifierFlags = MASPickCocoaModifiers(flags); +    } +    return self; +} + ++ (instancetype)shortcutWithKeyCode:(NSUInteger)code modifierFlags:(NSUInteger)flags +{ +    return [[self alloc] initWithKeyCode:code modifierFlags:flags]; +} + ++ (instancetype)shortcutWithEvent:(NSEvent *)event +{ +    return [[self alloc] initWithKeyCode:event.keyCode modifierFlags:event.modifierFlags]; +} + +#pragma mark Shortcut Accessors + +- (UInt32)carbonKeyCode +{ +    return (self.keyCode == NSNotFound ? 0 : (UInt32)self.keyCode); +} + +- (UInt32)carbonFlags +{ +    return MASCarbonModifiersFromCocoaModifiers(self.modifierFlags); +} + +- (NSString *)description +{ +    return [NSString stringWithFormat:@"%@%@", self.modifierFlagsString, self.keyCodeString]; +} + +- (NSString *)keyCodeStringForKeyEquivalent +{ +    NSString *keyCodeString = self.keyCodeString; +    if (keyCodeString.length > 1) { +        switch (self.keyCode) { +            case kVK_F1: return NSStringFromMASKeyCode(0xF704); +            case kVK_F2: return NSStringFromMASKeyCode(0xF705); +            case kVK_F3: return NSStringFromMASKeyCode(0xF706); +            case kVK_F4: return NSStringFromMASKeyCode(0xF707); +            case kVK_F5: return NSStringFromMASKeyCode(0xF708); +            case kVK_F6: return NSStringFromMASKeyCode(0xF709); +            case kVK_F7: return NSStringFromMASKeyCode(0xF70a); +            case kVK_F8: return NSStringFromMASKeyCode(0xF70b); +            case kVK_F9: return NSStringFromMASKeyCode(0xF70c); +            case kVK_F10: return NSStringFromMASKeyCode(0xF70d); +            case kVK_F11: return NSStringFromMASKeyCode(0xF70e); +            case kVK_F12: return NSStringFromMASKeyCode(0xF70f); +            // From this point down I am guessing F13 etc come sequentially, I don't have a keyboard to test. +            case kVK_F13: return NSStringFromMASKeyCode(0xF710); +            case kVK_F14: return NSStringFromMASKeyCode(0xF711); +            case kVK_F15: return NSStringFromMASKeyCode(0xF712); +            case kVK_F16: return NSStringFromMASKeyCode(0xF713); +            case kVK_F17: return NSStringFromMASKeyCode(0xF714); +            case kVK_F18: return NSStringFromMASKeyCode(0xF715); +            case kVK_F19: return NSStringFromMASKeyCode(0xF716); +            case kVK_Space: return NSStringFromMASKeyCode(0x20); +            default: return @""; +        } +    } +    return keyCodeString.lowercaseString; +} + +- (NSString *)keyCodeString +{ +    // Some key codes don't have an equivalent +    switch (self.keyCode) { +        case NSNotFound: return @""; +        case kVK_F1: return @"F1"; +        case kVK_F2: return @"F2"; +        case kVK_F3: return @"F3"; +        case kVK_F4: return @"F4"; +        case kVK_F5: return @"F5"; +        case kVK_F6: return @"F6"; +        case kVK_F7: return @"F7"; +        case kVK_F8: return @"F8"; +        case kVK_F9: return @"F9"; +        case kVK_F10: return @"F10"; +        case kVK_F11: return @"F11"; +        case kVK_F12: return @"F12"; +        case kVK_F13: return @"F13"; +        case kVK_F14: return @"F14"; +        case kVK_F15: return @"F15"; +        case kVK_F16: return @"F16"; +        case kVK_F17: return @"F17"; +        case kVK_F18: return @"F18"; +        case kVK_F19: return @"F19"; +        case kVK_Space: return NSLocalizedString(@"Space", @"Shortcut glyph name for SPACE key"); +        case kVK_Escape: return NSStringFromMASKeyCode(kMASShortcutGlyphEscape); +        case kVK_Delete: return NSStringFromMASKeyCode(kMASShortcutGlyphDeleteLeft); +        case kVK_ForwardDelete: return NSStringFromMASKeyCode(kMASShortcutGlyphDeleteRight); +        case kVK_LeftArrow: return NSStringFromMASKeyCode(kMASShortcutGlyphLeftArrow); +        case kVK_RightArrow: return NSStringFromMASKeyCode(kMASShortcutGlyphRightArrow); +        case kVK_UpArrow: return NSStringFromMASKeyCode(kMASShortcutGlyphUpArrow); +        case kVK_DownArrow: return NSStringFromMASKeyCode(kMASShortcutGlyphDownArrow); +        case kVK_Help: return NSStringFromMASKeyCode(kMASShortcutGlyphHelp); +        case kVK_PageUp: return NSStringFromMASKeyCode(kMASShortcutGlyphPageUp); +        case kVK_PageDown: return NSStringFromMASKeyCode(kMASShortcutGlyphPageDown); +        case kVK_Tab: return NSStringFromMASKeyCode(kMASShortcutGlyphTabRight); +        case kVK_Return: return NSStringFromMASKeyCode(kMASShortcutGlyphReturnR2L); +             +        // Keypad +        case kVK_ANSI_Keypad0: return @"0"; +        case kVK_ANSI_Keypad1: return @"1"; +        case kVK_ANSI_Keypad2: return @"2"; +        case kVK_ANSI_Keypad3: return @"3"; +        case kVK_ANSI_Keypad4: return @"4"; +        case kVK_ANSI_Keypad5: return @"5"; +        case kVK_ANSI_Keypad6: return @"6"; +        case kVK_ANSI_Keypad7: return @"7"; +        case kVK_ANSI_Keypad8: return @"8"; +        case kVK_ANSI_Keypad9: return @"9"; +        case kVK_ANSI_KeypadDecimal: return @"."; +        case kVK_ANSI_KeypadMultiply: return @"*"; +        case kVK_ANSI_KeypadPlus: return @"+"; +        case kVK_ANSI_KeypadClear: return NSStringFromMASKeyCode(kMASShortcutGlyphPadClear); +        case kVK_ANSI_KeypadDivide: return @"/"; +        case kVK_ANSI_KeypadEnter: return NSStringFromMASKeyCode(kMASShortcutGlyphReturn); +        case kVK_ANSI_KeypadMinus: return @"–"; +        case kVK_ANSI_KeypadEquals: return @"="; +             +        // Hardcode +        case 119: return NSStringFromMASKeyCode(kMASShortcutGlyphSoutheastArrow); +        case 115: return NSStringFromMASKeyCode(kMASShortcutGlyphNorthwestArrow); +    } +     +    // Everything else should be printable so look it up in the current keyboard +    OSStatus error = noErr; +    NSString *keystroke = nil; +    TISInputSourceRef inputSource = TISCopyCurrentKeyboardLayoutInputSource(); +    if (inputSource) { +        CFDataRef layoutDataRef = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData); +        if (layoutDataRef) { +            UCKeyboardLayout *layoutData = (UCKeyboardLayout *)CFDataGetBytePtr(layoutDataRef); +            UniCharCount length = 0; +            UniChar  chars[256] = { 0 }; +            UInt32 deadKeyState = 0; +            error = UCKeyTranslate(layoutData, (UInt16)self.keyCode, kUCKeyActionDisplay, 0, // No modifiers +                                   LMGetKbdType(), kUCKeyTranslateNoDeadKeysMask, &deadKeyState, +                                   sizeof(chars) / sizeof(UniChar), &length, chars); +            keystroke = ((error == noErr) && length ? [NSString stringWithCharacters:chars length:length] : @""); +        } +        CFRelease(inputSource); +    } +     +    // Validate keystroke +    if (keystroke.length) { +        static NSMutableCharacterSet *validChars = nil; +        if (validChars == nil) { +            validChars = [[NSMutableCharacterSet alloc] init]; +            [validChars formUnionWithCharacterSet:[NSCharacterSet alphanumericCharacterSet]]; +            [validChars formUnionWithCharacterSet:[NSCharacterSet punctuationCharacterSet]]; +            [validChars formUnionWithCharacterSet:[NSCharacterSet symbolCharacterSet]]; +        } +        for (NSUInteger i = 0, length = keystroke.length; i < length; i++) { +            if (![validChars characterIsMember:[keystroke characterAtIndex:i]]) { +                keystroke = @""; +                break; +            } +        } +    } +     +    // Finally, we've got a shortcut! +    return keystroke.uppercaseString; +} + +- (NSString *)modifierFlagsString +{ +    unichar chars[4]; +    NSUInteger count = 0; +    // These are in the same order as the menu manager shows them +    if (self.modifierFlags & NSControlKeyMask) chars[count++] = kControlUnicode; +    if (self.modifierFlags & NSAlternateKeyMask) chars[count++] = kOptionUnicode; +    if (self.modifierFlags & NSShiftKeyMask) chars[count++] = kShiftUnicode; +    if (self.modifierFlags & NSCommandKeyMask) chars[count++] = kCommandUnicode; +    return (count ? [NSString stringWithCharacters:chars length:count] : @""); +} + +#pragma mark NSObject + +- (BOOL) isEqual: (MASShortcut*) object +{ +    return [object isKindOfClass:[self class]] +        && (object.keyCode == self.keyCode) +        && (object.modifierFlags == self.modifierFlags); +} + +- (NSUInteger) hash +{ +    return self.keyCode + self.modifierFlags; +} + +#pragma mark NSCoding + +- (void)encodeWithCoder:(NSCoder *)coder +{ +    [coder encodeInteger:(self.keyCode != NSNotFound ? (NSInteger)self.keyCode : - 1) forKey:MASShortcutKeyCode]; +    [coder encodeInteger:(NSInteger)self.modifierFlags forKey:MASShortcutModifierFlags]; +} + +- (instancetype)initWithCoder:(NSCoder *)decoder +{ +    self = [super init]; +    if (self) { +        NSInteger code = [decoder decodeIntegerForKey:MASShortcutKeyCode]; +        _keyCode = (code < 0 ? NSNotFound : (NSUInteger)code); +        _modifierFlags = [decoder decodeIntegerForKey:MASShortcutModifierFlags]; +    } +    return self; +} + +#pragma mark NSSecureCoding + ++ (BOOL)supportsSecureCoding +{ +    return YES; +} + +#pragma mark NSCopying + +- (instancetype) copyWithZone:(NSZone *)zone +{ +    return [[self class] shortcutWithKeyCode:_keyCode modifierFlags:_modifierFlags]; +} + +@end diff --git a/Framework/MASShortcutBinder.h b/Framework/MASShortcutBinder.h new file mode 100644 index 0000000..1592e90 --- /dev/null +++ b/Framework/MASShortcutBinder.h @@ -0,0 +1,67 @@ +#import "MASShortcutMonitor.h" + +/** +    @brief Binds actions to user defaults keys. + +    If you store shortcuts in user defaults (for example by binding +    a @p MASShortcutView to user defaults), you can use this class to +    connect an action directly to a user defaults key. If the shortcut +    stored under the key changes, the action will get automatically +    updated to the new one. + +    This class is mostly a wrapper around a @p MASShortcutMonitor. It +    watches the changes in user defaults and updates the shortcut monitor +    accordingly with the new shortcuts. +*/ +@interface MASShortcutBinder : NSObject + +/** +    @brief A convenience shared instance. + +    You may use it so that you don’t have to manage an instance by hand, +    but it’s perfectly fine to allocate and use a separate instance instead. +*/ ++ (instancetype) sharedBinder; + +/** +    @brief The underlying shortcut monitor. +*/ +@property(strong) MASShortcutMonitor *shortcutMonitor; + +/** +    @brief Binding options customizing the access to user defaults. + +    As an example, you can use @p NSValueTransformerNameBindingOption to customize +    the storage format used for the shortcuts. By default the shortcuts are converted +    from @p NSData (@p NSKeyedUnarchiveFromDataTransformerName). Note that if the +    binder is to work with @p MASShortcutView, both object have to use the same storage +    format. +*/ +@property(copy) NSDictionary *bindingOptions; + +/** +    @brief Binds given action to a shortcut stored under the given defaults key. + +    In other words, no matter what shortcut you store under the given key, +    pressing it will always trigger the given action. +*/ +- (void) bindShortcutWithDefaultsKey: (NSString*) defaultsKeyName toAction: (dispatch_block_t) action; + +/** +    @brief Disconnect the binding between user defaults and action. + +    In other words, the shortcut stored under the given key will no longer trigger an action. +*/ +- (void) breakBindingWithDefaultsKey: (NSString*) defaultsKeyName; + +/** +    @brief Register default shortcuts in user defaults. + +    This is a convenience frontent to [NSUserDefaults registerDefaults]. +    The dictionary should contain a map of user defaults’ keys to appropriate +    keyboard shortcuts. The shortcuts will be transformed according to +    @p bindingOptions and registered using @p registerDefaults. +*/ +- (void) registerDefaultShortcuts: (NSDictionary*) defaultShortcuts; + +@end diff --git a/Framework/MASShortcutBinder.m b/Framework/MASShortcutBinder.m new file mode 100644 index 0000000..bf85c41 --- /dev/null +++ b/Framework/MASShortcutBinder.m @@ -0,0 +1,114 @@ +#import "MASShortcutBinder.h" +#import "MASShortcut.h" + +@interface MASShortcutBinder () +@property(strong) NSMutableDictionary *actions; +@property(strong) NSMutableDictionary *shortcuts; +@end + +@implementation MASShortcutBinder + +#pragma mark Initialization + +- (id) init +{ +    self = [super init]; +    [self setActions:[NSMutableDictionary dictionary]]; +    [self setShortcuts:[NSMutableDictionary dictionary]]; +    [self setShortcutMonitor:[MASShortcutMonitor sharedMonitor]]; +    [self setBindingOptions:@{NSValueTransformerNameBindingOption: NSKeyedUnarchiveFromDataTransformerName}]; +    return self; +} + +- (void) dealloc +{ +    for (NSString *bindingName in [_actions allKeys]) { +        [self unbind:bindingName]; +    } +} + ++ (instancetype) sharedBinder +{ +    static dispatch_once_t once; +    static MASShortcutBinder *sharedInstance; +    dispatch_once(&once, ^{ +        sharedInstance = [[self alloc] init]; +    }); +    return sharedInstance; +} + +#pragma mark Registration + +- (void) bindShortcutWithDefaultsKey: (NSString*) defaultsKeyName toAction: (dispatch_block_t) action +{ +    [_actions setObject:[action copy] forKey:defaultsKeyName]; +    [self bind:defaultsKeyName toObject:[NSUserDefaultsController sharedUserDefaultsController] +        withKeyPath:[@"values." stringByAppendingString:defaultsKeyName] options:_bindingOptions]; +} + +- (void) breakBindingWithDefaultsKey: (NSString*) defaultsKeyName +{ +    [_shortcutMonitor unregisterShortcut:[_shortcuts objectForKey:defaultsKeyName]]; +    [_shortcuts removeObjectForKey:defaultsKeyName]; +    [_actions removeObjectForKey:defaultsKeyName]; +    [self unbind:defaultsKeyName]; +} + +- (void) registerDefaultShortcuts: (NSDictionary*) defaultShortcuts +{ +    NSValueTransformer *transformer = [_bindingOptions valueForKey:NSValueTransformerBindingOption]; +    if (transformer == nil) { +        NSString *transformerName = [_bindingOptions valueForKey:NSValueTransformerNameBindingOption]; +        if (transformerName) { +            transformer = [NSValueTransformer valueTransformerForName:transformerName]; +        } +    } + +    NSAssert(transformer != nil, @"Can’t register default shortcuts without a transformer."); + +    [defaultShortcuts enumerateKeysAndObjectsUsingBlock:^(NSString *defaultsKey, MASShortcut *shortcut, BOOL *stop) { +        id value = [transformer reverseTransformedValue:shortcut]; +        [[NSUserDefaults standardUserDefaults] registerDefaults:@{defaultsKey:value}]; +    }]; +} + +#pragma mark Bindings + +- (BOOL) isRegisteredAction: (NSString*) name +{ +    return !![_actions objectForKey:name]; +} + +- (id) valueForUndefinedKey: (NSString*) key +{ +    return [self isRegisteredAction:key] ? +        [_shortcuts objectForKey:key] : +        [super valueForUndefinedKey:key]; +} + +- (void) setValue: (id) value forUndefinedKey: (NSString*) key +{ +    if (![self isRegisteredAction:key]) { +        [super setValue:value forUndefinedKey:key]; +        return; +    } + +    MASShortcut *newShortcut = value; +    MASShortcut *currentShortcut = [_shortcuts objectForKey:key]; + +    // Unbind previous shortcut if any +    if (currentShortcut != nil) { +        [_shortcutMonitor unregisterShortcut:currentShortcut]; +    } + +    // Just deleting the old shortcut +    if (newShortcut == nil) { +        return; +    } + +    // Bind new shortcut +    [_shortcuts setObject:newShortcut forKey:key]; +    [_shortcutMonitor registerShortcut:newShortcut withAction:[_actions objectForKey:key]]; +} + +@end diff --git a/Framework/MASShortcutBinderTests.m b/Framework/MASShortcutBinderTests.m new file mode 100644 index 0000000..9f90a94 --- /dev/null +++ b/Framework/MASShortcutBinderTests.m @@ -0,0 +1,98 @@ +static NSString *const SampleDefaultsKey = @"sampleShortcut"; + +@interface MASShortcutBinderTests : XCTestCase +@property(strong) MASShortcutBinder *binder; +@property(strong) MASShortcutMonitor *monitor; +@property(strong) NSUserDefaults *defaults; +@end + +@implementation MASShortcutBinderTests + +- (void) setUp +{ +    [super setUp]; +    [self setBinder:[[MASShortcutBinder alloc] init]]; +    [self setMonitor:[_binder shortcutMonitor]]; +    [self setDefaults:[[NSUserDefaults alloc] init]]; +    [_defaults removeObjectForKey:SampleDefaultsKey]; +} + +- (void) tearDown +{ +    [_monitor unregisterAllShortcuts]; +    [self setMonitor:nil]; +    [self setDefaults:nil]; +    [self setBinder:nil]; +    [super tearDown]; +} + +- (void) testInitialValueReading +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:1 modifierFlags:1]; +    [_defaults setObject:[NSKeyedArchiver archivedDataWithRootObject:shortcut] forKey:SampleDefaultsKey]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    XCTAssertTrue([_monitor isShortcutRegistered:shortcut], +        @"Pass the initial shortcut from defaults to shortcut monitor."); +} + +- (void) testValueChangeReading +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:1 modifierFlags:1]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    [_defaults setObject:[NSKeyedArchiver archivedDataWithRootObject:shortcut] forKey:SampleDefaultsKey]; +    XCTAssertTrue([_monitor isShortcutRegistered:shortcut], +        @"Pass the shortcut from defaults to shortcut monitor after defaults change."); +} + +- (void) testValueClearing +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:1 modifierFlags:1]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    [_defaults setObject:[NSKeyedArchiver archivedDataWithRootObject:shortcut] forKey:SampleDefaultsKey]; +    [_defaults removeObjectForKey:SampleDefaultsKey]; +    XCTAssertFalse([_monitor isShortcutRegistered:shortcut], +        @"Unregister shortcut from monitor after value is cleared from defaults."); +} + +- (void) testBindingRemoval +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:1 modifierFlags:1]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    [_defaults setObject:[NSKeyedArchiver archivedDataWithRootObject:shortcut] forKey:SampleDefaultsKey]; +    [_binder breakBindingWithDefaultsKey:SampleDefaultsKey]; +    XCTAssertFalse([_monitor isShortcutRegistered:shortcut], +        @"Unregister shortcut from monitor after binding was removed."); +} + +- (void) testRebinding +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:1 modifierFlags:1]; +    [_defaults setObject:[NSKeyedArchiver archivedDataWithRootObject:shortcut] forKey:SampleDefaultsKey]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    [_binder breakBindingWithDefaultsKey:SampleDefaultsKey]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    XCTAssertTrue([_monitor isShortcutRegistered:shortcut], +        @"Bind after unbinding."); +} + +- (void) testTransformerDeserialization +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:5 modifierFlags:1048576]; +    NSDictionary *storedShortcut = @{@"keyCode": @5, @"modifierFlags": @1048576}; +    [_defaults setObject:storedShortcut forKey:SampleDefaultsKey]; +    [_binder setBindingOptions:@{NSValueTransformerBindingOption:[MASDictionaryTransformer new]}]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    XCTAssertTrue([_monitor isShortcutRegistered:shortcut], +        @"Deserialize shortcut from user defaults using a custom transformer."); +} + +- (void) testDefaultShortcuts +{ +    MASShortcut *shortcut = [MASShortcut shortcutWithKeyCode:5 modifierFlags:1048576]; +    [_binder registerDefaultShortcuts:@{SampleDefaultsKey: shortcut}]; +    [_binder bindShortcutWithDefaultsKey:SampleDefaultsKey toAction:^{}]; +    XCTAssertTrue([_monitor isShortcutRegistered:shortcut], +        @"Bind shortcut using a default value."); +} + +@end diff --git a/Framework/MASShortcutMonitor.h b/Framework/MASShortcutMonitor.h new file mode 100644 index 0000000..609686a --- /dev/null +++ b/Framework/MASShortcutMonitor.h @@ -0,0 +1,27 @@ +#import "MASShortcut.h" + +/** +    @brief Executes action when a shortcut is pressed. + +    There can only be one instance of this class, otherwise things +    will probably not work. (There’s a Carbon event handler inside +    and there can only be one Carbon event handler of a given type.) +*/ +@interface MASShortcutMonitor : NSObject + +- (instancetype) init __unavailable; ++ (instancetype) sharedMonitor; + +/** +    @brief Register a shortcut along with an action. + +    Attempting to insert an already registered shortcut probably won’t work. +    It may burn your house or cut your fingers. You have been warned. +*/ +- (void) registerShortcut: (MASShortcut*) shortcut withAction: (dispatch_block_t) action; +- (BOOL) isShortcutRegistered: (MASShortcut*) shortcut; + +- (void) unregisterShortcut: (MASShortcut*) shortcut; +- (void) unregisterAllShortcuts; + +@end diff --git a/Framework/MASShortcutMonitor.m b/Framework/MASShortcutMonitor.m new file mode 100644 index 0000000..cb89ce1 --- /dev/null +++ b/Framework/MASShortcutMonitor.m @@ -0,0 +1,101 @@ +#import "MASShortcutMonitor.h" +#import "MASHotKey.h" + +@interface MASShortcutMonitor () +@property(assign) EventHandlerRef eventHandlerRef; +@property(strong) NSMutableDictionary *hotKeys; +@end + +static OSStatus MASCarbonEventCallback(EventHandlerCallRef, EventRef, void*); + +@implementation MASShortcutMonitor + +#pragma mark Initialization + +- (instancetype) init +{ +    self = [super init]; +    [self setHotKeys:[NSMutableDictionary dictionary]]; +    EventTypeSpec hotKeyPressedSpec = { .eventClass = kEventClassKeyboard, .eventKind = kEventHotKeyPressed }; +    OSStatus status = InstallEventHandler(GetEventDispatcherTarget(), MASCarbonEventCallback, +        1, &hotKeyPressedSpec, (__bridge void*)self, &_eventHandlerRef); +    if (status != noErr) { +        return nil; +    } +    return self; +} + +- (void) dealloc +{ +    if (_eventHandlerRef) { +        RemoveEventHandler(_eventHandlerRef); +        _eventHandlerRef = NULL; +    } +} + ++ (instancetype) sharedMonitor +{ +    static dispatch_once_t once; +    static MASShortcutMonitor *sharedInstance; +    dispatch_once(&once, ^{ +        sharedInstance = [[self alloc] init]; +    }); +    return sharedInstance; +} + +#pragma mark Registration + +- (void) registerShortcut: (MASShortcut*) shortcut withAction: (dispatch_block_t) action +{ +    MASHotKey *hotKey = [MASHotKey registeredHotKeyWithShortcut:shortcut]; +    [hotKey setAction:action]; +    [_hotKeys setObject:hotKey forKey:shortcut]; +} + +- (void) unregisterShortcut: (MASShortcut*) shortcut +{ +    [_hotKeys removeObjectForKey:shortcut]; +} + +- (void) unregisterAllShortcuts +{ +    [_hotKeys removeAllObjects]; +} + +- (BOOL) isShortcutRegistered: (MASShortcut*) shortcut +{ +    return !![_hotKeys objectForKey:shortcut]; +} + +#pragma mark Event Handling + +- (void) handleEvent: (EventRef) event +{ +    if (GetEventClass(event) != kEventClassKeyboard) { +        return; +    } + +    EventHotKeyID hotKeyID; +    OSStatus status = GetEventParameter(event, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof(hotKeyID), NULL, &hotKeyID); +    if (status != noErr || hotKeyID.signature != MASHotKeySignature) { +        return; +    } + +    [_hotKeys enumerateKeysAndObjectsUsingBlock:^(MASShortcut *shortcut, MASHotKey *hotKey, BOOL *stop) { +        if (hotKeyID.id == [hotKey carbonID]) { +            if ([hotKey action]) { +                dispatch_async(dispatch_get_main_queue(), [hotKey action]); +            } +            *stop = YES; +        } +    }]; +} + +@end + +static OSStatus MASCarbonEventCallback(EventHandlerCallRef _, EventRef event, void *context) +{ +    MASShortcutMonitor *dispatcher = (__bridge id)context; +    [dispatcher handleEvent:event]; +    return noErr; +} diff --git a/Framework/MASShortcutTests.m b/Framework/MASShortcutTests.m new file mode 100644 index 0000000..28eab56 --- /dev/null +++ b/Framework/MASShortcutTests.m @@ -0,0 +1,26 @@ +@interface MASShortcutTests : XCTestCase +@end + +@implementation MASShortcutTests + +- (void) testEquality +{ +    MASShortcut *keyA = [MASShortcut shortcutWithKeyCode:1 modifierFlags:NSControlKeyMask]; +    MASShortcut *keyB = [MASShortcut shortcutWithKeyCode:2 modifierFlags:NSControlKeyMask]; +    MASShortcut *keyC = [MASShortcut shortcutWithKeyCode:1 modifierFlags:NSAlternateKeyMask]; +    MASShortcut *keyD = [MASShortcut shortcutWithKeyCode:1 modifierFlags:NSControlKeyMask]; +    XCTAssertTrue([keyA isEqual:keyA], @"Shortcut is equal to itself."); +    XCTAssertTrue([keyA isEqual:[keyA copy]], @"Shortcut is equal to its copy."); +    XCTAssertFalse([keyA isEqual:keyB], @"Shortcuts not equal when key codes differ."); +    XCTAssertFalse([keyA isEqual:keyC], @"Shortcuts not equal when modifier flags differ."); +    XCTAssertTrue([keyA isEqual:keyD], @"Shortcuts are equal when key codes and modifiers are."); +    XCTAssertFalse([keyA isEqual:@"foo"], @"Shortcut not equal to an object of a different class."); +} + +- (void) testShortcutRecorderCompatibility +{ +    MASShortcut *key = [MASShortcut shortcutWithKeyCode:87 modifierFlags:1048576]; +    XCTAssertEqualObjects([key description], @"⌘5", @"Basic compatibility with the keycode & modifier combination used by Shortcut Recorder."); +} + +@end diff --git a/Framework/MASShortcutValidator.h b/Framework/MASShortcutValidator.h new file mode 100644 index 0000000..cc5f816 --- /dev/null +++ b/Framework/MASShortcutValidator.h @@ -0,0 +1,15 @@ +#import "MASShortcut.h" + +@interface MASShortcutValidator : NSObject + +// The following API enable hotkeys with the Option key as the only modifier +// For example, Option-G will not generate © and Option-R will not paste ® +@property(assign) BOOL allowAnyShortcutWithOptionModifier; + ++ (instancetype) sharedValidator; + +- (BOOL) isShortcutValid: (MASShortcut*) shortcut; +- (BOOL) isShortcut: (MASShortcut*) shortcut alreadyTakenInMenu: (NSMenu*) menu explanation: (NSString**) explanation; +- (BOOL) isShortcutAlreadyTakenBySystem: (MASShortcut*) shortcut explanation: (NSString**) explanation; + +@end diff --git a/Framework/MASShortcutValidator.m b/Framework/MASShortcutValidator.m new file mode 100644 index 0000000..47dd700 --- /dev/null +++ b/Framework/MASShortcutValidator.m @@ -0,0 +1,111 @@ +#import "MASShortcutValidator.h" + +@implementation MASShortcutValidator + ++ (instancetype) sharedValidator +{ +    static dispatch_once_t once; +    static MASShortcutValidator *sharedInstance; +    dispatch_once(&once, ^{ +        sharedInstance = [[self alloc] init]; +    }); +    return sharedInstance; +} + +- (BOOL) isShortcutValid: (MASShortcut*) shortcut +{ +    NSUInteger keyCode = [shortcut keyCode]; +    NSUInteger modifiers = [shortcut modifierFlags]; + +    // Allow any function key with any combination of modifiers +    BOOL includesFunctionKey = ((keyCode == kVK_F1) || (keyCode == kVK_F2) || (keyCode == kVK_F3) || (keyCode == kVK_F4) || +                                (keyCode == kVK_F5) || (keyCode == kVK_F6) || (keyCode == kVK_F7) || (keyCode == kVK_F8) || +                                (keyCode == kVK_F9) || (keyCode == kVK_F10) || (keyCode == kVK_F11) || (keyCode == kVK_F12) || +                                (keyCode == kVK_F13) || (keyCode == kVK_F14) || (keyCode == kVK_F15) || (keyCode == kVK_F16) || +                                (keyCode == kVK_F17) || (keyCode == kVK_F18) || (keyCode == kVK_F19) || (keyCode == kVK_F20)); +    if (includesFunctionKey) return YES; + +    // Do not allow any other key without modifiers +    BOOL hasModifierFlags = (modifiers > 0); +    if (!hasModifierFlags) return NO; + +    // Allow any hotkey containing Control or Command modifier +    BOOL includesCommand = ((modifiers & NSCommandKeyMask) > 0); +    BOOL includesControl = ((modifiers & NSControlKeyMask) > 0); +    if (includesCommand || includesControl) return YES; + +    // Allow Option key only in selected cases +    BOOL includesOption = ((modifiers & NSAlternateKeyMask) > 0); +    if (includesOption) { + +        // Always allow Option-Space and Option-Escape because they do not have any bind system commands +        if ((keyCode == kVK_Space) || (keyCode == kVK_Escape)) return YES; + +        // Allow Option modifier with any key even if it will break the system binding +        if (_allowAnyShortcutWithOptionModifier) return YES; +    } + +    // The hotkey does not have any modifiers or violates system bindings +    return NO; +} + +- (BOOL) isShortcut: (MASShortcut*) shortcut alreadyTakenInMenu: (NSMenu*) menu explanation: (NSString**) explanation +{ +    NSString *keyEquivalent = [shortcut keyCodeStringForKeyEquivalent]; +    NSUInteger flags = [shortcut modifierFlags]; + +    for (NSMenuItem *menuItem in menu.itemArray) { +        if (menuItem.hasSubmenu && [self isShortcut:shortcut alreadyTakenInMenu:[menuItem submenu] explanation:explanation]) return YES; +         +        BOOL equalFlags = (MASPickCocoaModifiers(menuItem.keyEquivalentModifierMask) == flags); +        BOOL equalHotkeyLowercase = [menuItem.keyEquivalent.lowercaseString isEqualToString:keyEquivalent]; +         +        // Check if the cases are different, we know ours is lower and that shift is included in our modifiers +        // If theirs is capitol, we need to add shift to their modifiers +        if (equalHotkeyLowercase && ![menuItem.keyEquivalent isEqualToString:keyEquivalent]) { +            equalFlags = (MASPickCocoaModifiers(menuItem.keyEquivalentModifierMask | NSShiftKeyMask) == flags); +        } +         +        if (equalFlags && equalHotkeyLowercase) { +            if (explanation) { +                *explanation = NSLocalizedString(@"This shortcut cannot be used because it is already used by the menu item ‘%@’.", +                                                     @"Message for alert when shortcut is already used"); +                *explanation = [NSString stringWithFormat:*explanation, menuItem.title]; +            } +            return YES; +        } +    } +    return NO; +} + +- (BOOL) isShortcutAlreadyTakenBySystem: (MASShortcut*) shortcut explanation: (NSString**) explanation +{ +    CFArrayRef globalHotKeys; +    if (CopySymbolicHotKeys(&globalHotKeys) == noErr) { + +        // Enumerate all global hotkeys and check if any of them matches current shortcut +        for (CFIndex i = 0, count = CFArrayGetCount(globalHotKeys); i < count; i++) { +            CFDictionaryRef hotKeyInfo = CFArrayGetValueAtIndex(globalHotKeys, i); +            CFNumberRef code = CFDictionaryGetValue(hotKeyInfo, kHISymbolicHotKeyCode); +            CFNumberRef flags = CFDictionaryGetValue(hotKeyInfo, kHISymbolicHotKeyModifiers); +            CFNumberRef enabled = CFDictionaryGetValue(hotKeyInfo, kHISymbolicHotKeyEnabled); + +            if (([(__bridge NSNumber *)code unsignedIntegerValue] == [shortcut keyCode]) && +                ([(__bridge NSNumber *)flags unsignedIntegerValue] == [shortcut carbonFlags]) && +                ([(__bridge NSNumber *)enabled boolValue])) { + +                if (explanation) { +                    *explanation = NSLocalizedString(@"This combination cannot be used because it is already used by a system-wide " +                                                     @"keyboard shortcut.\nIf you really want to use this key combination, most shortcuts " +                                                     @"can be changed in the Keyboard & Mouse panel in System Preferences.", +                                                     @"Message for alert when shortcut is already used by the system"); +                } +                return YES; +            } +        } +        CFRelease(globalHotKeys); +    } +    return [self isShortcut:shortcut alreadyTakenInMenu:[NSApp mainMenu] explanation:explanation]; +} + +@end diff --git a/Framework/MASShortcutView+Bindings.h b/Framework/MASShortcutView+Bindings.h new file mode 100644 index 0000000..b0148e7 --- /dev/null +++ b/Framework/MASShortcutView+Bindings.h @@ -0,0 +1,25 @@ +#import "MASShortcutView.h" + +/** +    @brief A simplified interface to bind the recorder value to user defaults. + +    You can bind the @p shortcutValue to user defaults using the standard +    @p bind:toObject:withKeyPath:options: call, but since that’s a lot to type +    and read, here’s a simpler option. + +    Setting the @p associatedUserDefaultsKey binds the view’s shortcut value +    to the given user defaults key. You can supply a value transformer to convert +    values between user defaults and @p MASShortcut. If you don’t supply +    a transformer, the @p NSUnarchiveFromDataTransformerName will be used +    automatically. + +    Set @p associatedUserDefaultsKey to @p nil to disconnect the binding. +*/ +@interface MASShortcutView (Bindings) + +@property(copy) NSString *associatedUserDefaultsKey; + +- (void) setAssociatedUserDefaultsKey: (NSString*) newKey withTransformer: (NSValueTransformer*) transformer; +- (void) setAssociatedUserDefaultsKey: (NSString*) newKey withTransformerName: (NSString*) transformerName; + +@end diff --git a/Framework/MASShortcutView+Bindings.m b/Framework/MASShortcutView+Bindings.m new file mode 100644 index 0000000..54c5111 --- /dev/null +++ b/Framework/MASShortcutView+Bindings.m @@ -0,0 +1,50 @@ +#import "MASShortcutView+Bindings.h" + +@implementation MASShortcutView (Bindings) + +- (NSString*) associatedUserDefaultsKey +{ +    NSDictionary* bindingInfo = [self infoForBinding:MASShortcutBinding]; +    if (bindingInfo != nil) { +        NSString *keyPath = [bindingInfo objectForKey:NSObservedKeyPathKey]; +        NSString *key = [keyPath stringByReplacingOccurrencesOfString:@"values." withString:@""]; +        return key; +    } else { +        return nil; +    } +} + +- (void) setAssociatedUserDefaultsKey: (NSString*) newKey withTransformer: (NSValueTransformer*) transformer +{ +    // Break previous binding if any +    NSString *currentKey = [self associatedUserDefaultsKey]; +    if (currentKey != nil) { +        [self unbind:currentKey]; +    } + +    // Stop if the new binding is nil +    if (newKey == nil) { +        return; +    } + +    NSDictionary *options = transformer ? +        @{NSValueTransformerBindingOption:transformer} : +        nil; + +    [self bind:MASShortcutBinding +        toObject:[NSUserDefaultsController sharedUserDefaultsController] +        withKeyPath:[@"values." stringByAppendingString:newKey] +        options:options]; +} + +- (void) setAssociatedUserDefaultsKey: (NSString*) newKey withTransformerName: (NSString*) transformerName +{ +    [self setAssociatedUserDefaultsKey:newKey withTransformer:[NSValueTransformer valueTransformerForName:transformerName]]; +} + +- (void) setAssociatedUserDefaultsKey: (NSString*) newKey +{ +    [self setAssociatedUserDefaultsKey:newKey withTransformerName:NSKeyedUnarchiveFromDataTransformerName]; +} + +@end diff --git a/Framework/MASShortcutView.h b/Framework/MASShortcutView.h new file mode 100644 index 0000000..166be44 --- /dev/null +++ b/Framework/MASShortcutView.h @@ -0,0 +1,24 @@ +@class MASShortcut, MASShortcutValidator; + +extern NSString *const MASShortcutBinding; + +typedef enum { +    MASShortcutViewStyleDefault = 0,  // Height = 19 px +    MASShortcutViewStyleTexturedRect, // Height = 25 px +    MASShortcutViewStyleRounded,      // Height = 43 px +    MASShortcutViewStyleFlat +} MASShortcutViewStyle; + +@interface MASShortcutView : NSView + +@property (nonatomic, strong) MASShortcut *shortcutValue; +@property (nonatomic, strong) MASShortcutValidator *shortcutValidator; +@property (nonatomic, getter = isRecording) BOOL recording; +@property (nonatomic, getter = isEnabled) BOOL enabled; +@property (nonatomic, copy) void (^shortcutValueChange)(MASShortcutView *sender); +@property (nonatomic, assign) MASShortcutViewStyle style; + +/// Returns custom class for drawing control. ++ (Class)shortcutCellClass; + +@end diff --git a/Framework/MASShortcutView.m b/Framework/MASShortcutView.m new file mode 100644 index 0000000..aace67e --- /dev/null +++ b/Framework/MASShortcutView.m @@ -0,0 +1,511 @@ +#import "MASShortcutView.h" +#import "MASShortcutValidator.h" + +NSString *const MASShortcutBinding = @"shortcutValue"; + +#define HINT_BUTTON_WIDTH 23.0 +#define BUTTON_FONT_SIZE 11.0 +#define SEGMENT_CHROME_WIDTH 6.0 + +#pragma mark - + +@interface MASShortcutView () // Private accessors + +@property (nonatomic, getter = isHinting) BOOL hinting; +@property (nonatomic, copy) NSString *shortcutPlaceholder; + +@end + +#pragma mark - + +@implementation MASShortcutView { +    NSButtonCell *_shortcutCell; +    NSInteger _shortcutToolTipTag; +    NSInteger _hintToolTipTag; +    NSTrackingArea *_hintArea; +} + +#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:BUTTON_FONT_SIZE]; +    _shortcutValidator = [MASShortcutValidator sharedValidator]; +    _enabled = YES; +    [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; +     +    if (_recording != flag) { +        _recording = flag; +        self.shortcutPlaceholder = nil; +        [self resetToolTips]; +        [self activateEventMonitoring:_recording]; +        [self activateResignObserver:_recording]; +        [self setNeedsDisplay:YES]; +    } +} + +- (void)setShortcutValue:(MASShortcut *)shortcutValue +{ +    _shortcutValue = shortcutValue; +    [self resetToolTips]; +    [self setNeedsDisplay:YES]; +    [self propagateValue:shortcutValue forBinding:@"shortcutValue"]; + +    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) { +        [self drawInRect:self.bounds withTitle:NSStringFromMASKeyCode(self.recording ? kMASShortcutGlyphEscape : kMASShortcutGlyphDeleteLeft) +               alignment:NSRightTextAlignment state:NSOffState]; +         +        CGRect shortcutRect; +        [self getShortcutRect:&shortcutRect hintRect:NULL]; +        NSString *title = (self.recording +                           ? (_hinting +                              ? NSLocalizedString(@"Use Old Shortcut", @"Cancel action button for non-empty shortcut in recording state") +                              : (self.shortcutPlaceholder.length > 0 +                                 ? self.shortcutPlaceholder +                                 : NSLocalizedString(@"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 +                               ? NSLocalizedString(@"Cancel", @"Cancel action button in recording state") +                               : (self.shortcutPlaceholder.length > 0 +                                  ? self.shortcutPlaceholder +                                  : NSLocalizedString(@"Type Shortcut", @"Empty shortcut button in recording state"))); +            [self drawInRect:shortcutRect withTitle:title alignment:NSCenterTextAlignment state:NSOnState]; +        } +        else +        { +            [self drawInRect:self.bounds withTitle:NSLocalizedString(@"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 = HINT_BUTTON_WIDTH; +    switch (self.style) { +        case MASShortcutViewStyleTexturedRect: hintButtonWidth += 2.0; break; +        case MASShortcutViewStyleRounded: hintButtonWidth += 3.0; break; +        case MASShortcutViewStyleFlat: hintButtonWidth -= 8.0 - (_shortcutCell.font.pointSize - BUTTON_FONT_SIZE); 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 NSLocalizedString(@"Click to record new shortcut", @"Tooltip for non-empty shortcut button"); +    } +    else if (data == kUserDataHint) { +        return NSLocalizedString(@"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) { +        __weak 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]; + +            // 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 = NSLocalizedString(@"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:NSLocalizedString(@"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) { +        __weak 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]; +} + +@end diff --git a/Framework/Prefix.pch b/Framework/Prefix.pch new file mode 100644 index 0000000..3e71c31 --- /dev/null +++ b/Framework/Prefix.pch @@ -0,0 +1,2 @@ +#import <AppKit/AppKit.h> +#import <Carbon/Carbon.h>
\ No newline at end of file diff --git a/Framework/Shortcut.h b/Framework/Shortcut.h new file mode 100644 index 0000000..e131395 --- /dev/null +++ b/Framework/Shortcut.h @@ -0,0 +1,7 @@ +#import "MASShortcut.h" +#import "MASShortcutValidator.h" +#import "MASShortcutMonitor.h" +#import "MASShortcutBinder.h" +#import "MASDictionaryTransformer.h" +#import "MASShortcutView.h" +#import "MASShortcutView+Bindings.h"
\ No newline at end of file  | 
