diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index e4b8eb529..8e07e39a4 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ 8B1D14E3A55936EBCCC1E807 /* MiddleRightTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA84AB4DB9E7EEFCE50AA6D8 /* MiddleRightTwelfthCalculation.swift */; }; 924919761FC491952752A147 /* TopCenterLeftTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9698D1D9EE0180D7C4468304 /* TopCenterLeftTwelfthCalculation.swift */; }; 944F25CD2CE5A144004B2FD2 /* PrefsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944F25CC2CE5A144004B2FD2 /* PrefsViewController.swift */; }; + 944F25CF2CE5A144004B2FD2 /* ShortcutRecordingObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944F25CE2CE5A144004B2FD2 /* ShortcutRecordingObserver.swift */; }; + 944F25D12CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944F25D02CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift */; }; 94E9B08E2C3B8D97004C7F41 /* MacTilingDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9B08D2C3B8D97004C7F41 /* MacTilingDefaults.swift */; }; 94E9B0902C3E4578004C7F41 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9B08F2C3E4578004C7F41 /* StringExtension.swift */; }; 9818E00D28B59205004AA524 /* CompoundSnapArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9818E00C28B59205004AA524 /* CompoundSnapArea.swift */; }; @@ -248,6 +250,8 @@ 8B8CA6901E6FE134E930135B /* UpperMiddleCenterLeftSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpperMiddleCenterLeftSixteenthCalculation.swift; sourceTree = ""; }; 918F847FF15D524B6DB2E6DA /* TopCenterRightSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopCenterRightSixteenthCalculation.swift; sourceTree = ""; }; 944F25CC2CE5A144004B2FD2 /* PrefsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsViewController.swift; sourceTree = ""; }; + 944F25CE2CE5A144004B2FD2 /* ShortcutRecordingObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecordingObserver.swift; sourceTree = ""; }; + 944F25D02CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecordingObserverTests.swift; sourceTree = ""; }; 94E9B08D2C3B8D97004C7F41 /* MacTilingDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTilingDefaults.swift; sourceTree = ""; }; 94E9B08F2C3E4578004C7F41 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 9698D1D9EE0180D7C4468304 /* TopCenterLeftTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopCenterLeftTwelfthCalculation.swift; sourceTree = ""; }; @@ -707,6 +711,7 @@ isa = PBXGroup; children = ( 9824701F22AF9B7E0037B409 /* RectangleTests.swift */, + 944F25D02CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift */, 9824702122AF9B7E0037B409 /* Info.plist */, ); path = RectangleTests; @@ -725,6 +730,7 @@ isa = PBXGroup; children = ( 944F25CC2CE5A144004B2FD2 /* PrefsViewController.swift */, + 944F25CE2CE5A144004B2FD2 /* ShortcutRecordingObserver.swift */, 98910B3D231130AF0066EC23 /* SettingsViewController.swift */, 983DD03F28A844BE00BF1EEE /* SnapAreaViewController.swift */, 98C97FFC25893B040061F01F /* Config.swift */, @@ -1096,6 +1102,7 @@ 9824704C22B189250037B409 /* WindowMover.swift in Sources */, 6490B39B27BF980F0056C220 /* TopRightEighthCalculation.swift in Sources */, 944F25CD2CE5A144004B2FD2 /* PrefsViewController.swift in Sources */, + 944F25CF2CE5A144004B2FD2 /* ShortcutRecordingObserver.swift in Sources */, 9818E01028B59396004AA524 /* HalvesCompoundCalculation.swift in Sources */, 988D066922EB4CCB004EABD7 /* LastThirdCalculation.swift in Sources */, 98A6EDEC2528FFC100F74B10 /* WindowActionCategory.swift in Sources */, @@ -1142,6 +1149,7 @@ buildActionMask = 2147483647; files = ( 9824702022AF9B7E0037B409 /* RectangleTests.swift in Sources */, + 944F25D12CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Rectangle/PrefsWindow/Config.swift b/Rectangle/PrefsWindow/Config.swift index 591a317a6..01dbd27a5 100644 --- a/Rectangle/PrefsWindow/Config.swift +++ b/Rectangle/PrefsWindow/Config.swift @@ -15,7 +15,7 @@ extension Defaults { var shortcuts = [String: Shortcut]() for action in WindowAction.active { - if let masShortcut = MASShortcutBinder.shared()?.value(forKey: action.name) as? MASShortcut { + if let masShortcut = ShortcutCycle.shortcut(for: action) { shortcuts[action.name] = Shortcut(masShortcut: masShortcut) } } diff --git a/Rectangle/PrefsWindow/PrefsViewController.swift b/Rectangle/PrefsWindow/PrefsViewController.swift index 04ce8d401..998b5878e 100644 --- a/Rectangle/PrefsWindow/PrefsViewController.swift +++ b/Rectangle/PrefsWindow/PrefsViewController.swift @@ -13,6 +13,7 @@ import ServiceManagement class PrefsViewController: NSViewController { var actionsToViews = [WindowAction: MASShortcutView]() + private let shortcutRecordingObserver = ShortcutRecordingObserver() @IBOutlet weak var leftHalfShortcutView: MASShortcutView! @IBOutlet weak var rightHalfShortcutView: MASShortcutView! @@ -119,6 +120,7 @@ class PrefsViewController: NSViewController { for (action, view) in actionsToViews { view.setAssociatedUserDefaultsKey(action.name, withTransformerName: MASDictionaryTransformerName) } + shortcutRecordingObserver.observe(Array(actionsToViews.values)) if Defaults.allowAnyShortcut.enabled { let passThroughValidator = PassthroughShortcutValidator() diff --git a/Rectangle/PrefsWindow/SettingsViewController.swift b/Rectangle/PrefsWindow/SettingsViewController.swift index f89ff7aa9..1dcdb116e 100644 --- a/Rectangle/PrefsWindow/SettingsViewController.swift +++ b/Rectangle/PrefsWindow/SettingsViewController.swift @@ -46,6 +46,7 @@ class SettingsViewController: NSViewController { private var aboutTodoWindowController: NSWindowController? private var extraSettingsPopover: NSPopover? + private let shortcutRecordingObserver = ShortcutRecordingObserver() private var cycleSizeCheckboxes = [NSButton]() @@ -811,6 +812,26 @@ class SettingsViewController: NSViewController { twelfthsCyclingShortcutView.shortcutValidator = passThroughValidator sixteenthsCyclingShortcutView.shortcutValidator = passThroughValidator } + shortcutRecordingObserver.observe([ + largerWidthShortcutView, + smallerWidthShortcutView, + topVerticalThirdShortcutView, + middleVerticalThirdShortcutView, + bottomVerticalThirdShortcutView, + topVerticalTwoThirdsShortcutView, + bottomVerticalTwoThirdsShortcutView, + topLeftEighthShortcutView, + topCenterLeftEighthShortcutView, + topCenterRightEighthShortcutView, + topRightEighthShortcutView, + bottomLeftEighthShortcutView, + bottomCenterLeftEighthShortcutView, + bottomCenterRightEighthShortcutView, + bottomRightEighthShortcutView, + ninthsCyclingShortcutView, + twelfthsCyclingShortcutView, + sixteenthsCyclingShortcutView + ]) mainStackView.addArrangedSubview(gridHeaderLabel) mainStackView.setCustomSpacing(4, after: gridHeaderLabel) @@ -936,6 +957,7 @@ class SettingsViewController: NSViewController { updateCheckForUpdatesTitle() initializeTodoModeSettings() + shortcutRecordingObserver.observe([toggleTodoShortcutView, reflowTodoShortcutView]) self.cycleSizeCheckboxes.forEach { $0.removeFromSuperview() @@ -980,6 +1002,8 @@ class SettingsViewController: NSViewController { todoAppSidePopUpButton.selectItem(withTag: Defaults.todoSidebarSide.value.rawValue) TodoManager.initToggleShortcut() TodoManager.initReflowShortcut() + toggleTodoShortcutView.shortcutValidator = TodoShortcutValidator(defaultsKey: TodoManager.toggleDefaultsKey) + reflowTodoShortcutView.shortcutValidator = TodoShortcutValidator(defaultsKey: TodoManager.reflowDefaultsKey) toggleTodoShortcutView.setAssociatedUserDefaultsKey(TodoManager.toggleDefaultsKey, withTransformerName: MASDictionaryTransformerName) reflowTodoShortcutView.setAssociatedUserDefaultsKey(TodoManager.reflowDefaultsKey, withTransformerName: MASDictionaryTransformerName) showHideTodoModeSettings(animated: false) diff --git a/Rectangle/PrefsWindow/ShortcutRecordingObserver.swift b/Rectangle/PrefsWindow/ShortcutRecordingObserver.swift new file mode 100644 index 000000000..11571d558 --- /dev/null +++ b/Rectangle/PrefsWindow/ShortcutRecordingObserver.swift @@ -0,0 +1,67 @@ +// +// ShortcutRecordingObserver.swift +// Rectangle +// + +import Cocoa +import MASShortcut + +class ShortcutRecordingObserver: NSObject { + + private static var recordingObservationContext = 0 + private var observedViews = [ObjectIdentifier: MASShortcutView]() + private var recordingViews = Set() + + func observe(_ views: [MASShortcutView]) { + for view in views { + let viewId = ObjectIdentifier(view) + guard observedViews[viewId] == nil else { continue } + + observedViews[viewId] = view + view.addObserver(self, + forKeyPath: "recording", + options: [.new], + context: &Self.recordingObservationContext) + } + } + + deinit { + for view in observedViews.values { + view.removeObserver(self, + forKeyPath: "recording", + context: &Self.recordingObservationContext) + } + } + + override func observeValue(forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey : Any]?, + context: UnsafeMutableRawPointer?) { + guard context == &Self.recordingObservationContext, + keyPath == "recording", + let view = object as? MASShortcutView + else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + + let newValue = change?[.newKey] + let isRecording = (newValue as? Bool) ?? (newValue as? NSNumber)?.boolValue ?? false + recordingChanged(for: view, isRecording: isRecording) + } + + func recordingChanged(for view: MASShortcutView, isRecording: Bool) { + let wasRecording = !recordingViews.isEmpty + let viewId = ObjectIdentifier(view) + if isRecording { + recordingViews.insert(viewId) + } else { + recordingViews.remove(viewId) + } + + let isRecordingAnyView = !recordingViews.isEmpty + guard wasRecording != isRecordingAnyView else { return } + Notification.Name.shortcutRecording.post(object: isRecordingAnyView) + } + +} diff --git a/Rectangle/ShortcutManager.swift b/Rectangle/ShortcutManager.swift index 7d5803a7e..015a36d9b 100644 --- a/Rectangle/ShortcutManager.swift +++ b/Rectangle/ShortcutManager.swift @@ -10,54 +10,81 @@ import Foundation import MASShortcut class ShortcutManager { - + let windowManager: WindowManager - + private var boundShortcutActions = Set() + private var shortcutIdentities = [WindowAction: ShortcutCycle.ShortcutIdentity]() + private var isUpdatingShortcutBindings = false + private var shortcutsSuspendedForRecording = false + init(windowManager: WindowManager) { self.windowManager = windowManager - + MASShortcutBinder.shared()?.bindingOptions = [NSBindingOption.valueTransformerName: MASDictionaryTransformerName] - + registerDefaults() bindShortcuts() - + subscribeAll(selector: #selector(windowActionTriggered)) - - Notification.Name.changeDefaults.onPost { _ in self.registerDefaults() } + + NotificationCenter.default.addObserver(self, selector: #selector(defaultShortcutsChanged), name: .changeDefaults, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsChanged), name: UserDefaults.didChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(shortcutRecordingChanged), name: .shortcutRecording, object: nil) } - + public func reloadFromDefaults() { - unsubscribe() + unsubscribeWindowActions() unbindShortcuts() registerDefaults() bindShortcuts() subscribeAll(selector: #selector(windowActionTriggered)) } - + public func bindShortcuts() { - for action in WindowAction.active { - MASShortcutBinder.shared()?.bindShortcut(withDefaultsKey: action.name, toAction: action.post) + guard !shortcutsSuspendedForRecording else { return } + + let shortcutsByAction = ShortcutCycle.shortcutsByAction() + let groups = ShortcutCycle.groups(shortcutsByAction: shortcutsByAction) + + shortcutIdentities = ShortcutCycle.shortcutIdentities(shortcutsByAction: shortcutsByAction) + + for group in groups { + let representativeAction = group.representativeAction + boundShortcutActions.insert(representativeAction) + + if group.isCycle { + MASShortcutBinder.shared()?.bindShortcut(withDefaultsKey: representativeAction.name, toAction: { [weak self] in + self?.executeCycle(group) + }) + } else { + MASShortcutBinder.shared()?.bindShortcut(withDefaultsKey: representativeAction.name, toAction: representativeAction.post) + } } } - + public func unbindShortcuts() { - for action in WindowAction.active { + isUpdatingShortcutBindings = true + defer { isUpdatingShortcutBindings = false } + + for action in Set(WindowAction.active).union(boundShortcutActions) { MASShortcutBinder.shared()?.breakBinding(withDefaultsKey: action.name) } + + boundShortcutActions.removeAll() } - + public func getKeyEquivalent(action: WindowAction) -> (String?, NSEvent.ModifierFlags)? { - guard let masShortcut = MASShortcutBinder.shared()?.value(forKey: action.name) as? MASShortcut else { return nil } + guard let masShortcut = ShortcutCycle.shortcut(for: action) else { return nil } return (masShortcut.keyCodeStringForKeyEquivalent, masShortcut.modifierFlags) } - + deinit { - unsubscribe() + NotificationCenter.default.removeObserver(self) } - + private func registerDefaults() { - + let defaultShortcuts = WindowAction.active.reduce(into: [String: MASShortcut]()) { dict, windowAction in guard let defaultShortcut = Defaults.alternateDefaultShortcuts.enabled ? windowAction.alternateDefault @@ -66,21 +93,26 @@ class ShortcutManager { let shortcut = MASShortcut(keyCode: defaultShortcut.keyCode, modifierFlags: NSEvent.ModifierFlags(rawValue: defaultShortcut.modifierFlags)) dict[windowAction.name] = shortcut } - + MASShortcutBinder.shared()?.registerDefaultShortcuts(defaultShortcuts) } - + @objc func windowActionTriggered(notification: NSNotification) { - guard var parameters = notification.object as? ExecutionParameters else { return } - + guard let parameters = notification.object as? ExecutionParameters else { return } + execute(parameters) + } + + private func execute(_ originalParameters: ExecutionParameters) { + var parameters = originalParameters + if MultiWindowManager.execute(parameters: parameters) { return } - + if TodoManager.execute(parameters: parameters) { return } - + // Check if repeat cycles displays if Defaults.subsequentExecutionMode.value == .cycleMonitor, parameters.action.classification != .size, @@ -91,7 +123,7 @@ class ShortcutManager { NSSound.beep() return } - + if isRepeatAction(parameters: parameters, windowElement: windowElement, windowId: windowId) { if let screen = ScreenDetection().detectScreens(using: windowElement)?.adjacentScreens?.next{ parameters = ExecutionParameters(parameters.action, updateRestoreRect: parameters.updateRestoreRect, screen: screen, windowElement: windowElement, windowId: windowId) @@ -100,12 +132,76 @@ class ShortcutManager { } } } - + windowManager.execute(parameters) } - + + private func executeCycle(_ group: ShortcutCycle.Group) { + guard let windowElement = AccessibilityElement.getFrontWindowElement(), + let windowId = windowElement.getWindowId() + else { + NSSound.beep() + return + } + + let lastAction = AppDelegate.windowHistory.lastRectangleActions[windowId] + if ShortcutCycle.isStale(lastAction: lastAction, currentWindowRect: windowElement.frame) { + AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) + } + + let selectedAction = ShortcutCycle.action( + in: group, + lastAction: AppDelegate.windowHistory.lastRectangleActions[windowId], + currentWindowRect: windowElement.frame + ) + execute(ExecutionParameters(selectedAction, windowElement: windowElement, windowId: windowId)) + } + + @objc private func defaultShortcutsChanged() { + registerDefaults() + reloadShortcutBindingsIfNeeded() + } + + @objc private func userDefaultsChanged(_ notification: Notification) { + reloadShortcutBindingsIfNeeded() + } + + private func reloadShortcutBindingsIfNeeded() { + guard !isUpdatingShortcutBindings && !shortcutsSuspendedForRecording else { return } + + let currentShortcuts = ShortcutCycle.shortcutsByAction() + let currentIdentities = ShortcutCycle.shortcutIdentities(shortcutsByAction: currentShortcuts) + guard currentIdentities != shortcutIdentities else { return } + + unbindShortcuts() + if !ApplicationToggle.shortcutsDisabled { + bindShortcuts() + } else { + shortcutIdentities = currentIdentities + } + } + + @objc private func shortcutRecordingChanged(_ notification: Notification) { + guard let isRecording = notification.object as? Bool else { return } + + if isRecording { + guard !shortcutsSuspendedForRecording else { return } + shortcutsSuspendedForRecording = true + unbindShortcuts() + } else { + guard shortcutsSuspendedForRecording else { return } + shortcutsSuspendedForRecording = false + if !ApplicationToggle.shortcutsDisabled { + bindShortcuts() + } else { + let currentShortcuts = ShortcutCycle.shortcutsByAction() + shortcutIdentities = ShortcutCycle.shortcutIdentities(shortcutsByAction: currentShortcuts) + } + } + } + private func isRepeatAction(parameters: ExecutionParameters, windowElement: AccessibilityElement, windowId: CGWindowID) -> Bool { - + if parameters.action == .maximize { if ScreenDetection().detectScreens(using: windowElement)?.currentScreen.visibleFrame.size == windowElement.frame.size { return true @@ -116,18 +212,114 @@ class ShortcutManager { } return false } - + private func subscribe(notification: WindowAction, selector: Selector) { NotificationCenter.default.addObserver(self, selector: selector, name: notification.notificationName, object: nil) } - - private func unsubscribe() { - NotificationCenter.default.removeObserver(self) + + private func unsubscribeWindowActions() { + for windowAction in WindowAction.active { + NotificationCenter.default.removeObserver(self, name: windowAction.notificationName, object: nil) + } } - + private func subscribeAll(selector: Selector) { for windowAction in WindowAction.active { subscribe(notification: windowAction, selector: selector) } } } + +struct ShortcutCycle { + + struct ShortcutIdentity: Hashable { + let keyCode: Int + let modifierFlags: UInt + + init(_ shortcut: MASShortcut) { + keyCode = shortcut.keyCode + modifierFlags = shortcut.modifierFlags.rawValue + } + } + + struct Group { + let shortcut: MASShortcut + let actions: [WindowAction] + + var representativeAction: WindowAction { actions[0] } + var isCycle: Bool { actions.count > 1 } + + func action(after previousAction: WindowAction?) -> WindowAction { + guard let previousAction, + let index = actions.firstIndex(of: previousAction) + else { + return representativeAction + } + + return actions[(index + 1) % actions.count] + } + } + + static func shortcut(for action: WindowAction, userDefaults: UserDefaults = .standard) -> MASShortcut? { + return shortcut(forDefaultsKey: action.name, userDefaults: userDefaults) + } + + static func shortcut(forDefaultsKey defaultsKey: String, userDefaults: UserDefaults = .standard) -> MASShortcut? { + guard let shortcutDict = userDefaults.dictionary(forKey: defaultsKey), + let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName)), + let shortcut = dictTransformer.transformedValue(shortcutDict) as? MASShortcut + else { + return nil + } + + return shortcut + } + + static func shortcutsByAction(actions: [WindowAction] = WindowAction.active, userDefaults: UserDefaults = .standard) -> [WindowAction: MASShortcut] { + actions.reduce(into: [WindowAction: MASShortcut]()) { dict, action in + if let shortcut = shortcut(for: action, userDefaults: userDefaults) { + dict[action] = shortcut + } + } + } + + static func shortcutIdentities(shortcutsByAction: [WindowAction: MASShortcut]) -> [WindowAction: ShortcutIdentity] { + shortcutsByAction.reduce(into: [WindowAction: ShortcutIdentity]()) { dict, item in + dict[item.key] = ShortcutIdentity(item.value) + } + } + + static func groups(actions: [WindowAction] = WindowAction.active, + shortcutsByAction: [WindowAction: MASShortcut]) -> [Group] { + var groups = [Group]() + var groupIndexesByShortcut = [ShortcutIdentity: Int]() + + for action in actions { + guard let shortcut = shortcutsByAction[action] else { continue } + + let identity = ShortcutIdentity(shortcut) + if let groupIndex = groupIndexesByShortcut[identity] { + let group = groups[groupIndex] + groups[groupIndex] = Group(shortcut: group.shortcut, actions: group.actions + [action]) + } else { + groupIndexesByShortcut[identity] = groups.count + groups.append(Group(shortcut: shortcut, actions: [action])) + } + } + + return groups + } + + static func action(in group: Group, lastAction: RectangleAction?, currentWindowRect: CGRect?) -> WindowAction { + guard !isStale(lastAction: lastAction, currentWindowRect: currentWindowRect) else { + return group.representativeAction + } + + return group.action(after: lastAction?.action) + } + + static func isStale(lastAction: RectangleAction?, currentWindowRect: CGRect?) -> Bool { + guard let lastAction, let currentWindowRect else { return false } + return currentWindowRect != lastAction.rect + } +} diff --git a/Rectangle/TodoMode/TodoManager.swift b/Rectangle/TodoMode/TodoManager.swift index f31c62b22..669f6353d 100644 --- a/Rectangle/TodoMode/TodoManager.swift +++ b/Rectangle/TodoMode/TodoManager.swift @@ -11,7 +11,7 @@ import MASShortcut class TodoManager { private static var todoWindowId: CGWindowID? - + static var todoScreen : NSScreen? static let toggleDefaultsKey = "toggleTodo" static let reflowDefaultsKey = "reflowTodo" @@ -22,7 +22,7 @@ class TodoManager { registerUnregisterReflowShortcut() moveAllIfNeeded(bringToFront) } - + static func initToggleShortcut() { if UserDefaults.standard.dictionary(forKey: toggleDefaultsKey) == nil { guard let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName)) else { return } @@ -46,6 +46,11 @@ class TodoManager { } private static func registerToggleShortcut() { + guard isTodoShortcutBindable(toggleDefaultsKey) else { + unregisterToggleShortcut() + return + } + MASShortcutBinder.shared()?.bindShortcut(withDefaultsKey: toggleDefaultsKey, toAction: { let enabled = !Defaults.todoMode.enabled setTodoMode(enabled) @@ -53,6 +58,11 @@ class TodoManager { } private static func registerReflowShortcut() { + guard isTodoShortcutBindable(reflowDefaultsKey) else { + unregisterReflowShortcut() + return + } + MASShortcutBinder.shared()?.bindShortcut(withDefaultsKey: reflowDefaultsKey, toAction: { moveAll() }) @@ -81,26 +91,30 @@ class TodoManager { unregisterReflowShortcut() } } - - static func getToggleKeyDisplay() -> (String?, NSEvent.ModifierFlags)? { + + private static func isTodoShortcutBindable(_ defaultsKey: String) -> Bool { + guard let shortcut = shortcut(for: defaultsKey) else { return true } + return TodoShortcutConflict.conflict(for: shortcut, ignoringTodoDefaultsKey: defaultsKey) == nil + } + + private static func shortcut(for defaultsKey: String, userDefaults: UserDefaults = .standard) -> MASShortcut? { guard - let shortcutDict = UserDefaults.standard.dictionary(forKey: toggleDefaultsKey), + let shortcutDict = userDefaults.dictionary(forKey: defaultsKey), let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName)), let shortcut = dictTransformer.transformedValue(shortcutDict) as? MASShortcut else { return nil } + return shortcut + } + + static func getToggleKeyDisplay() -> (String?, NSEvent.ModifierFlags)? { + guard let shortcut = shortcut(for: toggleDefaultsKey) else { return nil } return (shortcut.keyCodeStringForKeyEquivalent, shortcut.modifierFlags) } static func getReflowKeyDisplay() -> (String?, NSEvent.ModifierFlags)? { - guard - let shortcutDict = UserDefaults.standard.dictionary(forKey: reflowDefaultsKey), - let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName)), - let shortcut = dictTransformer.transformedValue(shortcutDict) as? MASShortcut - else { - return nil - } + guard let shortcut = shortcut(for: reflowDefaultsKey) else { return nil } return (shortcut.keyCodeStringForKeyEquivalent, shortcut.modifierFlags) } @@ -262,6 +276,73 @@ class TodoManager { } } +struct TodoShortcutConflict { + + let shortcutName: String + + static func conflict(for shortcut: MASShortcut, + ignoringTodoDefaultsKey ignoredDefaultsKey: String, + userDefaults: UserDefaults = .standard) -> TodoShortcutConflict? { + let identity = ShortcutCycle.ShortcutIdentity(shortcut) + + for action in WindowAction.active { + guard let actionShortcut = ShortcutCycle.shortcut(for: action, userDefaults: userDefaults), + ShortcutCycle.ShortcutIdentity(actionShortcut) == identity + else { continue } + + return TodoShortcutConflict(shortcutName: action.displayName ?? action.name) + } + + for defaultsKey in TodoManager.defaultsKeys where defaultsKey != ignoredDefaultsKey { + guard let todoShortcut = ShortcutCycle.shortcut(forDefaultsKey: defaultsKey, userDefaults: userDefaults), + ShortcutCycle.ShortcutIdentity(todoShortcut) == identity + else { continue } + + return TodoShortcutConflict(shortcutName: displayName(forTodoDefaultsKey: defaultsKey)) + } + + return nil + } + + private static func displayName(forTodoDefaultsKey defaultsKey: String) -> String { + switch defaultsKey { + case TodoManager.toggleDefaultsKey: + return NSLocalizedString("Toggle Todo", tableName: "Main", value: "Toggle Todo", comment: "") + case TodoManager.reflowDefaultsKey: + return NSLocalizedString("Reflow Todo", tableName: "Main", value: "Reflow Todo", comment: "") + default: + return defaultsKey + } + } +} + +class TodoShortcutValidator: MASShortcutValidator { + + private let defaultsKey: String + private let userDefaults: UserDefaults + + init(defaultsKey: String, userDefaults: UserDefaults = .standard) { + self.defaultsKey = defaultsKey + self.userDefaults = userDefaults + super.init() + } + + override func isShortcutValid(_ shortcut: MASShortcut!) -> Bool { + guard super.isShortcutValid(shortcut) else { return false } + + // Preserve previous behavior by rejecting Rectangle-internal conflicts quietly, + // without routing them through MASShortcut's "already used" alert. + return TodoShortcutConflict.conflict(for: shortcut, + ignoringTodoDefaultsKey: defaultsKey, + userDefaults: userDefaults) == nil + } + + override func isShortcutAlreadyTaken(bySystem shortcut: MASShortcut!, + explanation: AutoreleasingUnsafeMutablePointer!) -> Bool { + return super.isShortcutAlreadyTaken(bySystem: shortcut, explanation: explanation) + } +} + enum TodoSidebarSide: Int { case right = 1 case left = 2 diff --git a/Rectangle/Utilities/NotificationExtension.swift b/Rectangle/Utilities/NotificationExtension.swift index 3b15a77c2..60a263412 100644 --- a/Rectangle/Utilities/NotificationExtension.swift +++ b/Rectangle/Utilities/NotificationExtension.swift @@ -23,6 +23,7 @@ extension Notification.Name { static let defaultSnapAreas = Notification.Name("defaultSnapAreas") static let updateAvailability = Notification.Name("updateAvailability") static let showAdditionalSizesInMenuChanged = Notification.Name("showAdditionalSizesInMenuChanged") + static let shortcutRecording = Notification.Name("shortcutRecording") func post( center: NotificationCenter = NotificationCenter.default, @@ -48,4 +49,3 @@ extension Notification.Name { } } - diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index a6c7776bb..aabb5e807 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -6,6 +6,7 @@ // Copyright © 2019 Ryan Hanson. All rights reserved. // +import MASShortcut import XCTest @testable import Rectangle @@ -32,3 +33,163 @@ class RectangleTests: XCTestCase { } } + +class ShortcutCycleTests: XCTestCase { + + private func shortcut(_ keyCode: Int, _ flags: NSEvent.ModifierFlags) -> MASShortcut { + MASShortcut(keyCode: keyCode, modifierFlags: flags) + } + + func testUniqueShortcutsProduceSingletonGroups() { + let groups = ShortcutCycle.groups( + actions: [.centerHalf, .centerThird], + shortcutsByAction: [ + .centerHalf: shortcut(1, [.option, .command]), + .centerThird: shortcut(2, [.option, .command]) + ] + ) + + XCTAssertEqual(groups.map(\.actions), [[.centerHalf], [.centerThird]]) + XCTAssertFalse(groups.contains { $0.isCycle }) + } + + func testDuplicateShortcutsFollowWindowActionActiveOrder() { + let groups = ShortcutCycle.groups( + actions: WindowAction.active, + shortcutsByAction: [ + .centerHalf: shortcut(1, [.option, .command]), + .centerThird: shortcut(1, [.option, .command]) + ] + ) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.actions, [.centerHalf, .centerThird]) + } + + func testDuplicateShortcutStartsAtFirstActionWithoutPreviousAction() { + let group = ShortcutCycle.Group(shortcut: shortcut(1, [.option, .command]), actions: [.centerHalf, .centerThird]) + + XCTAssertEqual(group.action(after: nil), .centerHalf) + } + + func testDuplicateShortcutSelectsNextActionAndWraps() { + let group = ShortcutCycle.Group(shortcut: shortcut(1, [.option, .command]), actions: [.centerHalf, .centerThird]) + + XCTAssertEqual(group.action(after: .centerHalf), .centerThird) + XCTAssertEqual(group.action(after: .centerThird), .centerHalf) + } + + func testDuplicateShortcutStartsAtFirstActionWhenPreviousActionIsOutsideGroup() { + let group = ShortcutCycle.Group(shortcut: shortcut(1, [.option, .command]), actions: [.centerHalf, .centerThird]) + + XCTAssertEqual(group.action(after: .maximize), .centerHalf) + } + + func testStaleWindowHistoryIsIgnoredForCycleSelection() { + let group = ShortcutCycle.Group(shortcut: shortcut(1, [.option, .command]), actions: [.centerHalf, .centerThird]) + let lastAction = RectangleAction( + action: .centerHalf, + subAction: nil, + rect: CGRect(x: 0, y: 0, width: 500, height: 500), + count: 1 + ) + + let selectedAction = ShortcutCycle.action( + in: group, + lastAction: lastAction, + currentWindowRect: CGRect(x: 20, y: 20, width: 500, height: 500) + ) + + XCTAssertEqual(selectedAction, .centerHalf) + } + + func testDuplicateShortcutAssignmentsRemainReadableFromUserDefaults() { + let suiteName = "ShortcutCycleTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName)! + defer { + userDefaults.removePersistentDomain(forName: suiteName) + } + + let duplicatedShortcut = shortcut(1, [.option, .command]) + let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName))! + let shortcutDict = dictTransformer.reverseTransformedValue(duplicatedShortcut) + userDefaults.setValue(shortcutDict, forKey: WindowAction.centerHalf.name) + userDefaults.setValue(shortcutDict, forKey: WindowAction.centerThird.name) + + let shortcutsByAction = ShortcutCycle.shortcutsByAction(actions: [.centerHalf, .centerThird], userDefaults: userDefaults) + let groups = ShortcutCycle.groups(actions: [.centerHalf, .centerThird], shortcutsByAction: shortcutsByAction) + + XCTAssertNotNil(ShortcutCycle.shortcut(for: .centerHalf, userDefaults: userDefaults)) + XCTAssertNotNil(ShortcutCycle.shortcut(for: .centerThird, userDefaults: userDefaults)) + XCTAssertEqual(groups.map(\.actions), [[.centerHalf, .centerThird]]) + XCTAssertEqual(groups.first?.representativeAction, .centerHalf) + } +} + +class TodoShortcutValidatorTests: XCTestCase { + + private func shortcut(_ keyCode: Int, _ flags: NSEvent.ModifierFlags) -> MASShortcut { + MASShortcut(keyCode: keyCode, modifierFlags: flags) + } + + private func save(_ shortcut: MASShortcut, forKey key: String, in userDefaults: UserDefaults) { + let dictTransformer = ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName))! + let shortcutDict = dictTransformer.reverseTransformedValue(shortcut) + userDefaults.set(shortcutDict, forKey: key) + } + + private func userDefaultsSuite() -> (String, UserDefaults) { + let suiteName = "TodoShortcutValidatorTests.\(UUID().uuidString)" + return (suiteName, UserDefaults(suiteName: suiteName)!) + } + + func testInvalidatesShortcutUsedByWindowActionWithoutAlreadyTakenError() { + let (suiteName, userDefaults) = userDefaultsSuite() + defer { + userDefaults.removePersistentDomain(forName: suiteName) + } + + let duplicateShortcut = shortcut(1, [.option, .command]) + save(duplicateShortcut, forKey: WindowAction.centerHalf.name, in: userDefaults) + let validator = TodoShortcutValidator(defaultsKey: TodoManager.toggleDefaultsKey, userDefaults: userDefaults) + var explanation: NSString? + + let isTaken = validator.isShortcutAlreadyTaken(bySystem: duplicateShortcut, explanation: &explanation) + + XCTAssertFalse(validator.isShortcutValid(duplicateShortcut)) + XCTAssertFalse(isTaken) + XCTAssertNil(explanation) + } + + func testInvalidatesShortcutUsedByOtherTodoActionWithoutAlreadyTakenError() { + let (suiteName, userDefaults) = userDefaultsSuite() + defer { + userDefaults.removePersistentDomain(forName: suiteName) + } + + let duplicateShortcut = shortcut(1, [.option, .command]) + save(duplicateShortcut, forKey: TodoManager.reflowDefaultsKey, in: userDefaults) + let validator = TodoShortcutValidator(defaultsKey: TodoManager.toggleDefaultsKey, userDefaults: userDefaults) + var explanation: NSString? + + let isTaken = validator.isShortcutAlreadyTaken(bySystem: duplicateShortcut, explanation: &explanation) + + XCTAssertFalse(validator.isShortcutValid(duplicateShortcut)) + XCTAssertFalse(isTaken) + XCTAssertNil(explanation) + } + + func testAllowsExistingShortcutForSameTodoAction() { + let (suiteName, userDefaults) = userDefaultsSuite() + defer { + userDefaults.removePersistentDomain(forName: suiteName) + } + + let existingShortcut = shortcut(1, [.option, .command]) + save(existingShortcut, forKey: TodoManager.toggleDefaultsKey, in: userDefaults) + let validator = TodoShortcutValidator(defaultsKey: TodoManager.toggleDefaultsKey, userDefaults: userDefaults) + + XCTAssertTrue(validator.isShortcutValid(existingShortcut)) + XCTAssertFalse(validator.isShortcutAlreadyTaken(bySystem: existingShortcut, explanation: nil)) + } +} diff --git a/RectangleTests/ShortcutRecordingObserverTests.swift b/RectangleTests/ShortcutRecordingObserverTests.swift new file mode 100644 index 000000000..43988f2ff --- /dev/null +++ b/RectangleTests/ShortcutRecordingObserverTests.swift @@ -0,0 +1,93 @@ +// +// ShortcutRecordingObserverTests.swift +// RectangleTests +// + +import MASShortcut +import XCTest +@testable import Rectangle + +class ShortcutRecordingObserverTests: XCTestCase { + + func testPostsRecordingChangesForObservedShortcutViews() { + let observer = ShortcutRecordingObserver() + let firstShortcutView = MASShortcutView() + let secondShortcutView = MASShortcutView() + var recordingChanges = [Bool]() + let notificationObserver = NotificationCenter.default.addObserver( + forName: .shortcutRecording, + object: nil, + queue: nil + ) { notification in + recordingChanges.append(notification.object as! Bool) + } + defer { + NotificationCenter.default.removeObserver(notificationObserver) + firstShortcutView.setValue(false, forKey: "recording") + secondShortcutView.setValue(false, forKey: "recording") + } + + observer.observe([firstShortcutView, secondShortcutView]) + + firstShortcutView.setValue(true, forKey: "recording") + firstShortcutView.setValue(false, forKey: "recording") + secondShortcutView.setValue(true, forKey: "recording") + secondShortcutView.setValue(false, forKey: "recording") + + XCTAssertEqual(recordingChanges, [true, false, true, false]) + } + + func testObservingSameShortcutViewTwiceDoesNotDuplicateNotifications() { + let observer = ShortcutRecordingObserver() + let shortcutView = MASShortcutView() + var recordingChanges = [Bool]() + let notificationObserver = NotificationCenter.default.addObserver( + forName: .shortcutRecording, + object: nil, + queue: nil + ) { notification in + recordingChanges.append(notification.object as! Bool) + } + defer { + NotificationCenter.default.removeObserver(notificationObserver) + shortcutView.setValue(false, forKey: "recording") + } + + observer.observe([shortcutView]) + observer.observe([shortcutView]) + + shortcutView.setValue(true, forKey: "recording") + shortcutView.setValue(false, forKey: "recording") + + XCTAssertEqual(recordingChanges, [true, false]) + } + + func testOverlappingShortcutRecordingsStayActiveUntilAllViewsStopRecording() { + let observer = ShortcutRecordingObserver() + let firstShortcutView = MASShortcutView() + let secondShortcutView = MASShortcutView() + var recordingChanges = [Bool]() + let notificationObserver = NotificationCenter.default.addObserver( + forName: .shortcutRecording, + object: nil, + queue: nil + ) { notification in + recordingChanges.append(notification.object as! Bool) + } + defer { + NotificationCenter.default.removeObserver(notificationObserver) + } + + observer.recordingChanged(for: firstShortcutView, isRecording: true) + XCTAssertEqual(recordingChanges, [true]) + + observer.recordingChanged(for: secondShortcutView, isRecording: true) + XCTAssertEqual(recordingChanges, [true]) + + observer.recordingChanged(for: firstShortcutView, isRecording: false) + XCTAssertEqual(recordingChanges, [true]) + + observer.recordingChanged(for: secondShortcutView, isRecording: false) + XCTAssertEqual(recordingChanges, [true, false]) + } +}