diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index ebf596fc..a3cc9eb3 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ 5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8D57C86AF492D43A576D /* AppearanceTestable.swift */; }; 5F706EC02BA448B1004D542D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EBE2BA448B1004D542D /* Localizable.strings */; }; 5F706EC32BA448D8004D542D /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EC12BA448D8004D542D /* InfoPlist.strings */; }; + 5F882F382EC3C63400110A9F /* CustomRecorderControlTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */; }; 76D02BB22BFE7C9E0056008D /* Pods_alt_tab_macos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */; }; AA974BA929B7D84C0099A29E /* PreviewPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA974BA829B7D84C0099A29E /* PreviewPanel.swift */; }; BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B94D7994AB9007F6391 /* SkyLight.framework.swift */; }; @@ -129,6 +130,7 @@ BF0C86BB79E417163E813179 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C80851C26EDB3DB98556C /* InfoPlist.strings */; }; BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8BA452332236D972C60E /* ATShortcut.swift */; }; BF0C87732A6E660899D13BFD /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C886B1D6E707E9E6BE46C /* InfoPlist.strings */; }; + BF0C87B5EDCFC573CE220137 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C89231FD53C4D55E3CF9A /* Helpers.swift */; }; BF0C88449007A09F1D175029 /* KeyboardEventsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */; }; BF0C8846AFEE2FFF93FAB36F /* AppearanceTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8D57C86AF492D43A576D /* AppearanceTestable.swift */; }; BF0C884D88B74F05B8BBF956 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88A25E1ED37E5F80AC91 /* Localizable.strings */; }; @@ -153,6 +155,7 @@ BF0C8A739A74695E60F16369 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8062E78677398A4A217B /* InfoPlist.strings */; }; BF0C8A95AAD9DADC95887A2D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C895A9AF79A869EE6B108 /* Localizable.strings */; }; BF0C8AC57CE5E9BBEEE2442A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C885D34CDCE2AFC5A94F7 /* InfoPlist.strings */; }; + BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */; }; BF0C8B154051EC0D440339D4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8A9D25A9680EB19444BC /* Localizable.strings */; }; BF0C8B2E933E4A0A91D7AF64 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8195F23B30B8F4DD38D1 /* Localizable.strings */; }; BF0C8B2EE49F4753F663FEF2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8EF99FB3402D5A00B5C6 /* Localizable.strings */; }; @@ -172,6 +175,7 @@ BF0C8CBF5B4EE15FDB53C94B /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C861EE84489EF56879CBF /* InfoPlist.strings */; }; BF0C8CC5057406014FD612CC /* ATShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8BA452332236D972C60E /* ATShortcut.swift */; }; BF0C8CE3AE04A98400B9962F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D1BBBC02D57E16B454B /* InfoPlist.strings */; }; + BF0C8CEBBC2E851D16FF4757 /* CustomRecorderControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8308B436837FA3EDED86 /* CustomRecorderControlTests.swift */; }; BF0C8D1C57D74106018D073C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C87F5D4AD51D6950C98B0 /* InfoPlist.strings */; }; BF0C8D610C93D48F572937F8 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8FBA6B0F4DEB510C71CB /* Localizable.strings */; }; BF0C8DD7EBC3BA1E86BF4BDB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C841E403C1C6E8C65727E /* InfoPlist.strings */; }; @@ -390,6 +394,7 @@ BF0C82D44C2DFAA5D8F60394 /* sq */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sq; path = Localizable.strings; sourceTree = ""; }; BF0C82E4DE5A28800F1DA6E6 /* MissionControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MissionControl.swift; sourceTree = ""; }; BF0C82E6C6038F057A5D8D1E /* convert_svg_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_svg_to_png.sh; sourceTree = ""; }; + BF0C8308B436837FA3EDED86 /* CustomRecorderControlTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomRecorderControlTests.swift; sourceTree = ""; }; BF0C831AA76CF82EAC7B60B3 /* gl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gl; path = Localizable.strings; sourceTree = ""; }; BF0C83258B2873D9CDC74EA5 /* preferences-controls.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-controls.jpg"; sourceTree = ""; }; BF0C832DEED422648209CCD2 /* unit-tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "unit-tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -448,6 +453,7 @@ BF0C88C4940CA76FA801B881 /* preferences-appearance-details.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-appearance-details.jpg"; sourceTree = ""; }; BF0C890AF36F3EFD5CC4D114 /* bg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = bg; path = Localizable.strings; sourceTree = ""; }; BF0C89143F55C42D8A398C5A /* ku */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ku; path = Localizable.strings; sourceTree = ""; }; + BF0C89231FD53C4D55E3CF9A /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; BF0C892E4B0E4000C11DD102 /* ml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ml; path = InfoPlist.strings; sourceTree = ""; }; BF0C893EAE329CE01783B70D /* gl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gl; path = InfoPlist.strings; sourceTree = ""; }; BF0C8942D747B2B8E3A2BB53 /* da */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = da; path = Localizable.strings; sourceTree = ""; }; @@ -499,6 +505,7 @@ BF0C8D7DE89E594FEE6DA692 /* ta */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ta; path = Localizable.strings; sourceTree = ""; }; BF0C8D87C20B7308B16498B6 /* et */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = et; path = InfoPlist.strings; sourceTree = ""; }; BF0C8D9E024876AD9A92BE8D /* sr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sr; path = Localizable.strings; sourceTree = ""; }; + BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomRecorderControlTestable.swift; sourceTree = ""; }; BF0C8DCAC388B07C369E9771 /* test-base.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "test-base.xcconfig"; sourceTree = ""; }; BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpacesEvents.swift; sourceTree = ""; }; BF0C8DF173A246A0E0C30649 /* override_tags_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_tags_on_poeditor.ts; sourceTree = ""; }; @@ -1039,6 +1046,8 @@ BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */, BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */, BF0C8AE65DDFD6359BE12CB3 /* resources */, + BF0C8308B436837FA3EDED86 /* CustomRecorderControlTests.swift */, + BF0C89231FD53C4D55E3CF9A /* Helpers.swift */, ); path = "unit-tests"; sourceTree = ""; @@ -1430,6 +1439,7 @@ 1C9617C5839903D752070CF2 /* ImageTextButtonView.swift */, BF0C896FD4907CD85D62C124 /* WindowlessAppIndicator.swift */, BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */, + BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */, ); path = "generic-components"; sourceTree = ""; @@ -2345,6 +2355,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5F882F382EC3C63400110A9F /* CustomRecorderControlTestable.swift in Sources */, BF0C81FA62D810342F59291A /* AppearanceTests.swift in Sources */, 5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */, BF0C8B787C76D9B961CC2F45 /* KeyboardEventsTests.swift in Sources */, @@ -2355,6 +2366,8 @@ BF0C88FFEE7645A1966C7C4E /* AXUIElementTests.swift in Sources */, BF0C866DE5D740AF7756CDCF /* ImageScalingTests.swift in Sources */, BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */, + BF0C8CEBBC2E851D16FF4757 /* CustomRecorderControlTests.swift in Sources */, + BF0C87B5EDCFC573CE220137 /* Helpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2452,6 +2465,7 @@ BF0C88A9AB286CB7CDC1019E /* MainMenu.swift in Sources */, BF0C826C7F8C5F68732FD75B /* DebugMenu.swift in Sources */, BF0C8E5F4E4D5F7DAEE07C20 /* ThumbnailsPanelBackgroundView.swift in Sources */, + BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3418,6 +3432,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Test.xcscheme b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Test.xcscheme index d2d51093..db47c045 100644 --- a/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Test.xcscheme +++ b/alt-tab-macos.xcodeproj/xcshareddata/xcschemes/Test.xcscheme @@ -23,7 +23,7 @@ diff --git a/config/test-debug.xcconfig b/config/test-debug.xcconfig index 2e745ee5..0e0d4830 100644 --- a/config/test-debug.xcconfig +++ b/config/test-debug.xcconfig @@ -2,3 +2,7 @@ #include "Pods/Target Support Files/Pods-unit-tests/Pods-unit-tests.debug.xcconfig" #include "test-base.xcconfig" + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG // for macro `#if DEBUG` +OTHER_SWIFT_FLAGS = -driver-time-compilation -Xfrontend -warn-long-function-bodies=250 -Xfrontend -warn-long-expression-type-checking=250 // find slow compilation areas +SWIFT_COMPILATION_MODE = incremental diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 72dd4b7e..3cd1b202 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -5,4 +5,4 @@ set -ex xcodebuild -version xcodebuild -workspace alt-tab-macos.xcworkspace -scheme Release -showBuildSettings | grep SWIFT_VERSION -set -o pipefail && xcodebuild test -workspace alt-tab-macos.xcworkspace -scheme Test | scripts/xcbeautify +set -o pipefail && xcodebuild test -workspace alt-tab-macos.xcworkspace -scheme Test -configuration Release | scripts/xcbeautify diff --git a/src/ui/generic-components/CustomRecorderControl.swift b/src/ui/generic-components/CustomRecorderControl.swift index 8d2881ec..a11fc35f 100644 --- a/src/ui/generic-components/CustomRecorderControl.swift +++ b/src/ui/generic-components/CustomRecorderControl.swift @@ -1,4 +1,5 @@ import Cocoa +import Carbon.HIToolbox.Events import ShortcutRecorder class CustomRecorderControl: RecorderControl { @@ -36,25 +37,25 @@ class CustomRecorderControl: RecorderControl { set(allowedModifierFlags: CustomRecorderControl.allowedModifiers.subtracting(restrictedModifiers), requiredModifierFlags: [], allowsEmptyModifierFlags: true) } - func alertIfSameShortcutAlreadyAssigned(_ shortcut: Shortcut, _ shortcutAlreadyAssigned: ATShortcut) { - let isArrowKeys = ["←", "→", "↑", "↓"].contains(shortcutAlreadyAssigned.id) - let isVimKeys = shortcutAlreadyAssigned.id.starts(with: "vimCycle") - let existingShortcutLabel = ControlsTab.shortcutControls[shortcutAlreadyAssigned.id] + func alertIfSameShortcutAlreadyAssigned(_ candidateId: String, _ candidateShortcut: Shortcut, _ shortcutAlreadyAssigned: String) { + let isArrowKeys = ["←", "→", "↑", "↓"].contains(shortcutAlreadyAssigned) + let isVimKeys = shortcutAlreadyAssigned.starts(with: "vimCycle") + let existingShortcut = ControlsTab.shortcutControls[shortcutAlreadyAssigned]! let alert = NSAlert() alert.alertStyle = .warning alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "") alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to another action: %@", comment: ""), - (isArrowKeys ? "Arrow keys" : (isVimKeys ? "Vim keys" : existingShortcutLabel!.1)).replacingOccurrences(of: " ", with: "\u{00A0}")) - if !id.starts(with: "holdShortcut") { + (isArrowKeys ? "Arrow keys" : (isVimKeys ? "Vim keys" : existingShortcut.1)).replacingOccurrences(of: " ", with: "\u{00A0}")) + if !candidateId.starts(with: "holdShortcut") { alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")).setAccessibilityFocused(true) } let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) cancelButton.keyEquivalent = "\u{1b}" - if id.starts(with: "holdShortcut") { + if candidateId.starts(with: "holdShortcut") { cancelButton.setAccessibilityFocused(true) } let userChoice = alert.runModal() - if !id.starts(with: "holdShortcut") && userChoice == .alertFirstButtonReturn { + if !candidateId.starts(with: "holdShortcut") && userChoice == .alertFirstButtonReturn { if isArrowKeys { ControlsTab.arrowKeysCheckbox.state = .off ControlsTab.arrowKeysEnabledCallback(ControlsTab.arrowKeysCheckbox) @@ -64,61 +65,49 @@ class CustomRecorderControl: RecorderControl { ControlsTab.vimKeysEnabledCallback(ControlsTab.vimKeysCheckbox) LabelAndControl.controlWasChanged(ControlsTab.vimKeysCheckbox, nil) } else { - existingShortcutLabel!.0.objectValue = nil - ControlsTab.shortcutChangedCallback(existingShortcutLabel!.0) - LabelAndControl.controlWasChanged(existingShortcutLabel!.0, shortcutAlreadyAssigned.id) + existingShortcut.0.objectValue = nil + ControlsTab.shortcutChangedCallback(existingShortcut.0) + LabelAndControl.controlWasChanged(existingShortcut.0, shortcutAlreadyAssigned) } - ControlsTab.shortcutControls[id]!.0.objectValue = shortcut + ControlsTab.shortcutControls[candidateId]!.0.objectValue = candidateShortcut ControlsTab.shortcutChangedCallback(self) - LabelAndControl.controlWasChanged(self, id) + LabelAndControl.controlWasChanged(self, candidateId) } } - private func isShortcutAlreadyAssigned(_ shortcut: Shortcut) -> ATShortcut? { - let comboShortcutName = id.starts(with: "holdShortcut") ? - Preferences.indexToName("nextWindowShortcut", Preferences.nameToIndex(id)) : - (id.starts(with: "nextWindowShortcut") ? - Preferences.indexToName("holdShortcut", Preferences.nameToIndex(id)) : id) - let comboShortcut = comboShortcutName.flatMap { ControlsTab.shortcuts[$0]?.shortcut } - return (ControlsTab.shortcuts.first { (id2, s2) in - let shortcut2 = s2.shortcut - if id == id2 - || (shortcut2.keyCode == .none && shortcut2.carbonModifierFlags == 0) - || id2.starts(with: "holdShortcut") - || ((id.starts(with: "holdShortcut") || id.starts(with: "nextWindowShortcut")) && id2 == comboShortcutName) { - return false - } - if shortcut2.keyCode != (id.starts(with: "holdShortcut") ? comboShortcut?.keyCode : shortcut.keyCode) { - return false - } - if id.starts(with: "holdShortcut") { - if ((comboShortcut?.carbonModifierFlags ?? 0) ^ (ControlsTab.shortcuts[id]?.shortcut.carbonModifierFlags ?? 0) | shortcut.carbonModifierFlags) - != (shortcut.carbonModifierFlags | shortcut2.carbonModifierFlags) { - return false - } - } else if id.starts(with: "nextWindowShortcut") { - if ((comboShortcut?.carbonModifierFlags ?? 0) | shortcut.carbonModifierFlags) - != ((comboShortcut?.carbonModifierFlags ?? 0) | shortcut2.carbonModifierFlags) { - return false - } - } else if !ControlsTab.combinedModifiersMatch(shortcut2.carbonModifierFlags, shortcut.carbonModifierFlags) { - return false - } - return true - })? - .value + func alertIfShortcutReservedByMacos(_ candidateId: String, _ candidateShortcut: Shortcut, _ shortcutUsingEscape: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "") + alert.informativeText = NSLocalizedString("The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃", comment: "") + alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")) + let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) + cancelButton.keyEquivalent = "\u{1b}" + cancelButton.setAccessibilityFocused(true) + let userChoice = alert.runModal() + if userChoice == .alertFirstButtonReturn { + guard candidateId != shortcutUsingEscape else { return } + let existingShortcut = ControlsTab.shortcutControls[shortcutUsingEscape]! + existingShortcut.0.objectValue = nil + ControlsTab.shortcutChangedCallback(existingShortcut.0) + LabelAndControl.controlWasChanged(existingShortcut.0, shortcutUsingEscape) + ControlsTab.shortcutControls[candidateId]!.0.objectValue = candidateShortcut + ControlsTab.shortcutChangedCallback(self) + LabelAndControl.controlWasChanged(self, candidateId) + } } } extension CustomRecorderControl: RecorderControlDelegate { - /// only allow modifiers: ⌥ -> valid, e -> invalid, ⌥e -> invalid func recorderControl(_ control: RecorderControl, canRecord shortcut: Shortcut) -> Bool { - if !clearable && shortcut.keyCode != .none { - return false - } - if let shortcutAlreadyAssigned = isShortcutAlreadyAssigned(shortcut) { - alertIfSameShortcutAlreadyAssigned(shortcut, shortcutAlreadyAssigned) + switch CustomRecorderControlTestable.isShortcutAcceptable(id, shortcut) { + case .accepted: return true + case .modifiersOnlyButContainsKeycode: return false + case .conflictWithExistingShortcut(let s): alertIfSameShortcutAlreadyAssigned(id, shortcut, s) + case .reservedByMacos(let s): alertIfShortcutReservedByMacos(id, shortcut, s) } return true } -} \ No newline at end of file +} + + diff --git a/src/ui/generic-components/CustomRecorderControlTestable.swift b/src/ui/generic-components/CustomRecorderControlTestable.swift new file mode 100644 index 00000000..d279d014 --- /dev/null +++ b/src/ui/generic-components/CustomRecorderControlTestable.swift @@ -0,0 +1,171 @@ +import Cocoa +import Carbon.HIToolbox.Events +import ShortcutRecorder + +class CustomRecorderControlTestable { + static func isShortcutAcceptable(_ candidateId: String, _ candidateShortcut: Shortcut) -> ShortcutAcceptance { + if candidateId.starts(with: "holdShortcut") && candidateShortcut.keyCode != .none { + return .modifiersOnlyButContainsKeycode + } + let newCombos = newCombinationsFromCandidate(candidateId, candidateShortcut) + let oldCombos = oldCombinationsExcludingTargetOfCandidate(candidateId) + // TODO: a user can assign a shortcut that's both .conflictWithExistingShortcut and .reservedByMacos + // It's a mess to deal with. Current implem may let them bypass isReservedByMacos. Would be nice to think about what UX to do here + if let alreadyAssigned = isAlreadyUsedByAnotherShortcut(newCombos, oldCombos) { + return .conflictWithExistingShortcut(shortcutAlreadyAssigned: alreadyAssigned) + } + if let shortcutUsingEscape = isReservedByMacos(newCombos) { + return .reservedByMacos(shortcutUsingEscape: shortcutUsingEscape) + } + return .accepted + } + + static func newCombinationsFromCandidate(_ candidateId: String, _ candidateShortcut: Shortcut) -> [(String, Shortcut)] { + var combos = [(String, Shortcut)]() + for atShortcut in ControlsTab.shortcuts.values { + guard atShortcut.id != candidateId + && !((candidateId.starts(with: "holdShortcut") || candidateId.starts(with: "nextWindowShortcut")) && (atShortcut.id.starts(with: "holdShortcut") || atShortcut.id.starts(with: "nextWindowShortcut")) && Preferences.nameToIndex(candidateId) != Preferences.nameToIndex(atShortcut.id)) + && !(atShortcut.shortcut.keyCode == .none && atShortcut.shortcut.modifierFlags == []) else { continue } + // candidate is holdShortcut + if candidateId.starts(with: "holdShortcut") { + // combine with local shortcut + if (!atShortcut.id.starts(with: "holdShortcut") && !atShortcut.id.starts(with: "nextWindowShortcut")) + // combine with nextWindowShortcut of same index + || (atShortcut.id.starts(with: "nextWindowShortcut") && Preferences.nameToIndex(candidateId) == Preferences.nameToIndex(atShortcut.id)) { + combos.append((atShortcut.id, Shortcut(code: atShortcut.shortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil))) + } + // candidate is nextWindowShortcut + } else if candidateId.starts(with: "nextWindowShortcut") { + // combine with holdShortcut of same index + if (atShortcut.id.starts(with: "holdShortcut") && Preferences.nameToIndex(candidateId) == Preferences.nameToIndex(atShortcut.id)) { + combos.append((candidateId, Shortcut(code: candidateShortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil))) + } + // candidate is a local shortcut + } else { + // combine with everything except a nextWindowShortcut + if !atShortcut.id.starts(with: "nextWindowShortcut") { + combos.append((candidateId, Shortcut(code: candidateShortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil))) + } + } + } + return combos + } + + static func oldCombinationsExcludingTargetOfCandidate(_ candidateId: String) -> [(String, Shortcut)] { + var holds = [ATShortcut]() + var nonHolds = [ATShortcut]() + for atShortcut in ControlsTab.shortcuts.values { + guard !((candidateId.starts(with: "holdShortcut") || candidateId.starts(with: "nextWindowShortcut")) && (atShortcut.id.starts(with: "holdShortcut") || atShortcut.id.starts(with: "nextWindowShortcut")) && Preferences.nameToIndex(candidateId) == Preferences.nameToIndex(atShortcut.id) ) + && !(atShortcut.shortcut.keyCode == .none && atShortcut.shortcut.modifierFlags == []) else { continue } + if atShortcut.id.starts(with: "holdShortcut") { + holds.append(atShortcut) + } else { + nonHolds.append(atShortcut) + } + } + var combos = [(String, Shortcut)]() + for hold in holds { + for nonHold in nonHolds { + guard !nonHold.id.starts(with: "nextWindowShortcut") || Preferences.nameToIndex(nonHold.id) == Preferences.nameToIndex(hold.id) else { continue } + combos.append((nonHold.id, Shortcut(code: nonHold.shortcut.keyCode, modifierFlags: [hold.shortcut.modifierFlags, nonHold.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil))) + } + } + return combos + } + + static func isReservedByMacos(_ newCombinationsFromCandidate: [(String, Shortcut)] ) -> String? { + for r in (ReservedMacosShortcut.allCases.map { $0.basicShortcut }) { + for c in newCombinationsFromCandidate { + if c.1.carbonKeyCode == r.carbonKeyCode && c.1.carbonModifierFlags == r.carbonModifierFlags { + return c.0 + } + } + } + return nil + } + + static func isAlreadyUsedByAnotherShortcut(_ newCombos: [(String, Shortcut)] , _ oldCombos: [(String, Shortcut)]) -> String? { + for newCombo in newCombos { + for oldCombo in oldCombos { + guard !(newCombo.0 == oldCombo.0) else { continue } + if (newCombo.1.keyCode == oldCombo.1.keyCode && newCombo.1.modifierFlags == oldCombo.1.modifierFlags) + // special case when 2 nextWindowShortcuts are modifiers-only (e.g. S1: alt + shift, S2: alt+command + shift) + // they will conflict if they their holdShortcuts are included in the other's holdShortcuts + || (newCombo.1.keyCode == .none && oldCombo.1.keyCode == .none && (newCombo.1.modifierFlags.isSuperset(of: oldCombo.1.modifierFlags) || oldCombo.1.modifierFlags.isSuperset(of: newCombo.1.modifierFlags))) { + return oldCombo.0 + } + } + } + return nil + } + + /// commandTab and commandKeyAboveTab are self-contained in the "nextWindowShortcut" shortcuts + /// but the keys of commandShiftTab can be spread between holdShortcut and a local shortcut + static func combinedModifiersMatch(_ modifiers1: UInt32, _ modifiers2: UInt32) -> Bool { + return (0.. Bool { + switch (lhs, rhs) { + case (.accepted, .accepted), + (.modifiersOnlyButContainsKeycode, .modifiersOnlyButContainsKeycode), + (.reservedByMacos, .reservedByMacos), + (.conflictWithExistingShortcut, .conflictWithExistingShortcut): + return true + default: + return false + } + } +} + +enum ReservedMacosShortcut: CaseIterable { + case forceQuitApplicationsDialog + case forceQuitActiveApp + case reservedForUnknownReason + + var basicShortcut: Shortcut { + switch self { + case .forceQuitApplicationsDialog: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option], characters: nil, charactersIgnoringModifiers: nil) + case .forceQuitActiveApp: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift], characters: nil, charactersIgnoringModifiers: nil) + case .reservedForUnknownReason: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift, .control], characters: nil, charactersIgnoringModifiers: nil) + } + } +} + +extension Shortcut { + /// ShortcutRecorder doesn't provide a Shortcut.toString() that outputs the same format as Shortcut.init(keyEquivalent:) + /// This helper provides this, on a limited range of shortcuts, since it used only for testing + var keyEquivalent: String { + get { + let readableStringRepresentation = self.readableStringRepresentation(isASCII: true).lowercased() + // for keycode-only shortcuts, readableStringRepresentation works + if keyCode != .none && modifierFlags == [] && !readableStringRepresentation.isEmpty { + return readableStringRepresentation + } + // for other shortcuts, we need to replace modifiers with unicode symbols (e.g. "Option-" -> "⌥") + let flags: [(NSEvent.ModifierFlags, String)] = [ + (.command, "⌘"), + (.option, "⌥"), + (.control, "⌃"), + (.shift, "⇧") + ] + let mods = flags.reduce("") { + $0 + (self.modifierFlags.contains($1.0) ? $1.1 : "") + } + let chars = readableStringRepresentation.replacingOccurrences(of: "\\b(command|option|shift|control)-", with: "", options: .regularExpression) + return mods + chars + } + } +} diff --git a/src/ui/preferences-window/tabs/controls/ControlsTab.swift b/src/ui/preferences-window/tabs/controls/ControlsTab.swift index 3151b9ab..280b2093 100644 --- a/src/ui/preferences-window/tabs/controls/ControlsTab.swift +++ b/src/ui/preferences-window/tabs/controls/ControlsTab.swift @@ -1,4 +1,5 @@ import Cocoa +import Carbon.HIToolbox.Events import ShortcutRecorder class ControlsTab { @@ -157,21 +158,10 @@ class ControlsTab { } } - /// commandTab and commandKeyAboveTab are self-contained in the "nextWindowShortcut" shortcuts - /// but the keys of commandShiftTab can be spread between holdShortcut and a local shortcut - static func combinedModifiersMatch(_ modifiers1: UInt32, _ modifiers2: UInt32) -> Bool { - return (0.. Bool] = [ .commandTab: { (shortcut) in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_Tab }, - .commandShiftTab: { (shortcut) in combinedModifiersMatch(shortcut.carbonModifierFlags, UInt32(cmdKey | shiftKey)) && shortcut.carbonKeyCode == kVK_Tab }, + .commandShiftTab: { (shortcut) in CustomRecorderControlTestable.combinedModifiersMatch(shortcut.carbonModifierFlags, UInt32(cmdKey | shiftKey)) && shortcut.carbonKeyCode == kVK_Tab }, .commandKeyAboveTab: { (shortcut) in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_ANSI_Grave }, ] var overlappingHotkeys = shortcuts.values.compactMap { (atShortcut) in nativeHotkeys.first { $1(atShortcut.shortcut) }?.key } diff --git a/src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift b/src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift index aa2212b2..400bc93f 100644 --- a/src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift +++ b/src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift @@ -3,21 +3,21 @@ import Cocoa class ShortcutsWhenActiveSheet: SheetWindow { override func makeContentView() -> NSView { let focusWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Focus selected window", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Focus selected window", comment: ""), "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]]) let previousWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Select previous window", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]]) let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel and hide", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]]) let closeWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Close window", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]]) let minDeminWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Minimize/Deminimize window", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "minDeminWindowShortcut", Preferences.minDeminWindowShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Minimize/Deminimize window", comment: ""), "minDeminWindowShortcut", Preferences.minDeminWindowShortcut, labelPosition: .right)[0]]) let toggleFullscreenWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Fullscreen/Defullscreen window", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "toggleFullscreenWindowShortcut", Preferences.toggleFullscreenWindowShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Fullscreen/Defullscreen window", comment: ""), "toggleFullscreenWindowShortcut", Preferences.toggleFullscreenWindowShortcut, labelPosition: .right)[0]]) let quitAppShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Quit app", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Quit app", comment: ""), "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right)[0]]) let hideShowAppShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Hide/Show app", comment: ""), - rightViews: [LabelAndControl.makeLabelWithRecorder("", "hideShowAppShortcut", Preferences.hideShowAppShortcut, labelPosition: .right)[0]]) + rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hide/Show app", comment: ""), "hideShowAppShortcut", Preferences.hideShowAppShortcut, labelPosition: .right)[0]]) let table = TableGroupView(title: NSLocalizedString("Shortcuts When Active", comment: ""), width: SheetWindow.width) _ = table.addRow(focusWindowShortcut) _ = table.addRow(previousWindowShortcut) diff --git a/unit-tests/CustomRecorderControlTests.swift b/unit-tests/CustomRecorderControlTests.swift new file mode 100644 index 00000000..16233508 --- /dev/null +++ b/unit-tests/CustomRecorderControlTests.swift @@ -0,0 +1,63 @@ +import XCTest +import ShortcutRecorder + +final class CustomRecorderControlTests: XCTestCase { + func testIsShortcutAcceptable() { + // .accepted + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⇧⇥")!), .accepted) + ControlsTab.shortcuts["holdShortcut"] = ATShortcut(Shortcut(keyEquivalent: "⌘⌥")!, "holdShortcut", .global, .up) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌥")!), .accepted) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + + // .modifiersOnlyButContainsKeycode + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⇧")!), .accepted) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘e")!), .modifiersOnlyButContainsKeycode) + + // .reservedByMacos + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted) // ⌘⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⇧")!), .accepted) // ⌘⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⌃⇧")!), .accepted) // ⌘⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⌥")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⌥⇧")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⇧⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⌥⌃⇧")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⌃⇧⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("cancelShortcut", Shortcut(keyEquivalent: "⌘⇧⎋")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⇧⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("cancelShortcut", Shortcut(keyEquivalent: "⌘⇧⌃⎋")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⇧⌃⎋ + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("cancelShortcut", Shortcut(keyEquivalent: "⌘⎋")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⎋ + + // .conflictWithExistingShortcut + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleRight", Shortcut(keyEquivalent: "l")!), .accepted) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇧⇥")!), .accepted) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleLeft", Shortcut(keyEquivalent: "h")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "hideShowAppShortcut")) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇥")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut")) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "previousWindowShortcut")) + + // alt + shift+tab / alt+shift + tab => pressing tab is ambiguous which one should trigger + ControlsTab.shortcuts["previousWindowShortcut"] = ATShortcut(Shortcut(keyEquivalent: "p")!, "previousWindowShortcut", .local, .down) + ControlsTab.shortcuts["nextWindowShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⇧⇥")!, "nextWindowShortcut2", .global, .down) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌥⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut2")) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + + // alt + shift / alt+command + shift => doesn't work if allowed + ControlsTab.shortcuts["nextWindowShortcut"] = ATShortcut(Shortcut(keyEquivalent: "⇧")!, "nextWindowShortcut", .global, .down) + ControlsTab.shortcuts["holdShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⌘⌥")!, "holdShortcut2", .global, .up) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut")) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + + // Shortcut 1: alt + tab / Shortcut 2: alt + command+tab => works if allowed + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⌘⇥")!), .accepted) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + + // Shortcut 1: alt + tab / Shortcut 2: alt+command + tab [assign Shortcut 2 last] => works if allowed + ControlsTab.shortcuts["holdShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⌘⌥")!, "holdShortcut2", .global, .up) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇥")!), .accepted) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + + // Shortcut 1: alt + tab / Shortcut 2: alt+command + tab [assign Shortcut 1 last] => works if allowed + ControlsTab.shortcuts["holdShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⌘⌥")!, "holdShortcut2", .global, .up) + ControlsTab.shortcuts["nextWindowShortcut"] = ATShortcut(Shortcut(keyEquivalent: "t")!, "nextWindowShortcut", .global, .down) + ControlsTab.shortcuts["nextWindowShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⇥")!, "nextWindowShortcut2", .global, .down) + XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇥")!), .accepted) + ControlsTab.shortcuts = ControlsTab.defaultShortcuts + } +} diff --git a/unit-tests/Helpers.swift b/unit-tests/Helpers.swift new file mode 100644 index 00000000..6a387dfc --- /dev/null +++ b/unit-tests/Helpers.swift @@ -0,0 +1,11 @@ +import XCTest + +func XCTAssertArraysEqualUnordered( + _ array1: [T], + _ array2: [T], + _ message: @autoclosure () -> String = "", + file: StaticString = #file, + line: UInt = #line +) { + XCTAssertEqual(array1.sorted(), array2.sorted(), message(), file: file, line: line) +} diff --git a/unit-tests/Mocks.swift b/unit-tests/Mocks.swift index eb05f608..d23d5759 100644 --- a/unit-tests/Mocks.swift +++ b/unit-tests/Mocks.swift @@ -10,7 +10,7 @@ class App { } class ControlsTab { - static let shortcuts = [ + static let defaultShortcuts = [ "holdShortcut": ATShortcut(Shortcut(keyEquivalent: "⌥")!, "holdShortcut", .global, .up, 0), "holdShortcut2": ATShortcut(Shortcut(keyEquivalent: "⌥")!, "holdShortcut2", .global, .up, 1), "holdShortcut3": ATShortcut(Shortcut(keyEquivalent: "⌥")!, "holdShortcut3", .global, .up, 2), @@ -33,6 +33,7 @@ class ControlsTab { "quitAppShortcut": ATShortcut(Shortcut(keyEquivalent: "q")!, "quitAppShortcut", .local, .down), "hideShowAppShortcut": ATShortcut(Shortcut(keyEquivalent: "h")!, "hideShowAppShortcut", .local, .down), ] + static var shortcuts = defaultShortcuts static func executeAction(_ action: String) { shortcutsActionsTriggered.append(action) @@ -62,6 +63,7 @@ class Logger { class Preferences { static var shortcutStyle: [ShortcutStylePreference] = [.focusOnRelease, .focusOnRelease, .focusOnRelease, .focusOnRelease] + static var holdShortcut = ["⌥", "⌥", "⌥"] static func indexToName(_ baseName: String, _ index: Int) -> String { return baseName + (index == 0 ? "" : String(index + 1))