diff --git a/Rectangle/ShortcutManager.swift b/Rectangle/ShortcutManager.swift index 47cbf06a..ed10b874 100644 --- a/Rectangle/ShortcutManager.swift +++ b/Rectangle/ShortcutManager.swift @@ -193,6 +193,8 @@ class ShortcutManager { shortcutIdentities = ShortcutCycle.shortcutIdentities(shortcutsByAction: currentShortcuts) } } + + TodoManager.setShortcutBindingsSuspended(isRecording) } private func isRepeatAction(parameters: ExecutionParameters, windowElement: AccessibilityElement, windowId: CGWindowID) -> Bool { diff --git a/Rectangle/TodoMode/TodoManager.swift b/Rectangle/TodoMode/TodoManager.swift index 0bb367e6..1f7aac56 100644 --- a/Rectangle/TodoMode/TodoManager.swift +++ b/Rectangle/TodoMode/TodoManager.swift @@ -10,6 +10,7 @@ class TodoManager { static let toggleDefaultsKey = "toggleTodo" static let reflowDefaultsKey = "reflowTodo" static let defaultsKeys = [toggleDefaultsKey, reflowDefaultsKey] + private static var shortcutBindingsSuspended = false static func setTodoMode(_ enabled: Bool, _ bringToFront: Bool = true) { Defaults.todoMode.enabled = enabled @@ -71,7 +72,7 @@ class TodoManager { } static func registerUnregisterToggleShortcut() { - if Defaults.todo.userEnabled { + if Defaults.todo.userEnabled && !shortcutBindingsSuspended { registerToggleShortcut() } else { unregisterToggleShortcut() @@ -79,13 +80,20 @@ class TodoManager { } static func registerUnregisterReflowShortcut() { - if Defaults.todo.userEnabled && Defaults.todoMode.enabled { + if Defaults.todo.userEnabled && Defaults.todoMode.enabled && !shortcutBindingsSuspended { registerReflowShortcut() } else { unregisterReflowShortcut() } } + static func setShortcutBindingsSuspended(_ suspended: Bool) { + guard shortcutBindingsSuspended != suspended else { return } + shortcutBindingsSuspended = suspended + registerUnregisterToggleShortcut() + registerUnregisterReflowShortcut() + } + private static func isTodoShortcutBindable(_ defaultsKey: String) -> Bool { guard let shortcut = shortcut(for: defaultsKey) else { return true } return TodoShortcutConflict.conflict(for: shortcut, ignoringTodoDefaultsKey: defaultsKey) == nil diff --git a/RectangleTests/ShortcutRecordingObserverTests.swift b/RectangleTests/ShortcutRecordingObserverTests.swift index add15ba5..b065f443 100644 --- a/RectangleTests/ShortcutRecordingObserverTests.swift +++ b/RectangleTests/ShortcutRecordingObserverTests.swift @@ -6,6 +6,92 @@ import XCTest class ShortcutRecordingObserverTests: XCTestCase { + private func withRegisteredTodoShortcuts( + _ assertions: (MASShortcutMonitor, MASShortcut, MASShortcut) throws -> Void + ) throws { + let userDefaults = UserDefaults.standard + let previousToggleShortcut = userDefaults.object(forKey: TodoManager.toggleDefaultsKey) + let previousReflowShortcut = userDefaults.object(forKey: TodoManager.reflowDefaultsKey) + let previousTodoEnabled = Defaults.todo.enabled + let previousTodoModeEnabled = Defaults.todoMode.enabled + let binder = try XCTUnwrap(MASShortcutBinder.shared()) + let previousBindingOptions = binder.bindingOptions + binder.bindingOptions = [NSBindingOption.valueTransformerName: MASDictionaryTransformerName] + + TodoManager.setShortcutBindingsSuspended(true) + defer { + TodoManager.setShortcutBindingsSuspended(true) + + if let previousToggleShortcut { + userDefaults.set(previousToggleShortcut, forKey: TodoManager.toggleDefaultsKey) + } else { + userDefaults.removeObject(forKey: TodoManager.toggleDefaultsKey) + } + if let previousReflowShortcut { + userDefaults.set(previousReflowShortcut, forKey: TodoManager.reflowDefaultsKey) + } else { + userDefaults.removeObject(forKey: TodoManager.reflowDefaultsKey) + } + + Defaults.todo.enabled = previousTodoEnabled + Defaults.todoMode.enabled = previousTodoModeEnabled + TodoManager.setShortcutBindingsSuspended(false) + binder.bindingOptions = previousBindingOptions + } + + let monitor = try XCTUnwrap(binder.shortcutMonitor) + let shortcuts = try availableTodoShortcuts(monitor: monitor) + let transformer = try XCTUnwrap( + ValueTransformer(forName: NSValueTransformerName(rawValue: MASDictionaryTransformerName)) + ) + + userDefaults.set( + transformer.reverseTransformedValue(shortcuts.toggle), + forKey: TodoManager.toggleDefaultsKey + ) + userDefaults.set( + transformer.reverseTransformedValue(shortcuts.reflow), + forKey: TodoManager.reflowDefaultsKey + ) + Defaults.todo.enabled = true + Defaults.todoMode.enabled = true + TodoManager.setShortcutBindingsSuspended(false) + + XCTAssertTrue(monitor.isShortcutRegistered(shortcuts.toggle)) + XCTAssertTrue(monitor.isShortcutRegistered(shortcuts.reflow)) + + try assertions(monitor, shortcuts.toggle, shortcuts.reflow) + } + + private func availableTodoShortcuts( + monitor: MASShortcutMonitor + ) throws -> (toggle: MASShortcut, reflow: MASShortcut) { + let modifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift] + let keyCodes = [ + kVK_ANSI_U, + kVK_ANSI_I, + kVK_ANSI_O, + kVK_ANSI_P, + kVK_ANSI_J, + kVK_ANSI_K, + kVK_ANSI_L, + kVK_ANSI_M + ] + let windowShortcutIdentities = Set( + ShortcutCycle.shortcutsByAction().values.map { ShortcutCycle.ShortcutIdentity($0) } + ) + let availableShortcuts = keyCodes + .map { MASShortcut(keyCode: $0, modifierFlags: modifiers) } + .filter { + !windowShortcutIdentities.contains(ShortcutCycle.ShortcutIdentity($0)) + && !monitor.isShortcutRegistered($0) + } + + let toggle = try XCTUnwrap(availableShortcuts.first) + let reflow = try XCTUnwrap(availableShortcuts.dropFirst().first) + return (toggle, reflow) + } + func testPostsRecordingChangesForObservedShortcutViews() { let observer = ShortcutRecordingObserver() let firstShortcutView = MASShortcutView() @@ -87,4 +173,48 @@ class ShortcutRecordingObserverTests: XCTestCase { observer.recordingChanged(for: secondShortcutView, isRecording: false) XCTAssertEqual(recordingChanges, [true, false]) } + + func testTodoShortcutBindingsAreSuspendedAndRestored() throws { + try withRegisteredTodoShortcuts { monitor, toggleShortcut, reflowShortcut in + TodoManager.setShortcutBindingsSuspended(true) + + XCTAssertFalse(monitor.isShortcutRegistered(toggleShortcut)) + XCTAssertFalse(monitor.isShortcutRegistered(reflowShortcut)) + + TodoManager.setShortcutBindingsSuspended(false) + + XCTAssertTrue(monitor.isShortcutRegistered(toggleShortcut)) + XCTAssertTrue(monitor.isShortcutRegistered(reflowShortcut)) + } + } + + func testTodoShortcutBindingsCannotRebindWhileSuspended() throws { + try withRegisteredTodoShortcuts { monitor, toggleShortcut, reflowShortcut in + TodoManager.setShortcutBindingsSuspended(true) + + TodoManager.registerUnregisterToggleShortcut() + TodoManager.registerUnregisterReflowShortcut() + + XCTAssertFalse(monitor.isShortcutRegistered(toggleShortcut)) + XCTAssertFalse(monitor.isShortcutRegistered(reflowShortcut)) + } + } + + func testResumingTodoShortcutBindingsHonorsTodoState() throws { + try withRegisteredTodoShortcuts { monitor, toggleShortcut, reflowShortcut in + TodoManager.setShortcutBindingsSuspended(true) + Defaults.todoMode.enabled = false + TodoManager.setShortcutBindingsSuspended(false) + + XCTAssertTrue(monitor.isShortcutRegistered(toggleShortcut)) + XCTAssertFalse(monitor.isShortcutRegistered(reflowShortcut)) + + TodoManager.setShortcutBindingsSuspended(true) + Defaults.todo.enabled = false + TodoManager.setShortcutBindingsSuspended(false) + + XCTAssertFalse(monitor.isShortcutRegistered(toggleShortcut)) + XCTAssertFalse(monitor.isShortcutRegistered(reflowShortcut)) + } + } }