feat: improve dealing with reserved/conflicting shortcuts

(closes #3190, closes #3288, closes #1835, closes #3826)
This commit is contained in:
lwouis committed 2025-11-15 18:47:59 +01:00
1 parent 011fdba7d3
commit fdf313472c
11 files changed
+321 -76

No files matched your search

+15
View File
@@ -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 = "<group>"; };
BF0C82E4DE5A28800F1DA6E6 /* MissionControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MissionControl.swift; sourceTree = "<group>"; };
BF0C82E6C6038F057A5D8D1E /* convert_svg_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_svg_to_png.sh; sourceTree = "<group>"; };
BF0C8308B436837FA3EDED86 /* CustomRecorderControlTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomRecorderControlTests.swift; sourceTree = "<group>"; };
BF0C831AA76CF82EAC7B60B3 /* gl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gl; path = Localizable.strings; sourceTree = "<group>"; };
BF0C83258B2873D9CDC74EA5 /* preferences-controls.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-controls.jpg"; sourceTree = "<group>"; };
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 = "<group>"; };
BF0C890AF36F3EFD5CC4D114 /* bg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = bg; path = Localizable.strings; sourceTree = "<group>"; };
BF0C89143F55C42D8A398C5A /* ku */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ku; path = Localizable.strings; sourceTree = "<group>"; };
BF0C89231FD53C4D55E3CF9A /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
BF0C892E4B0E4000C11DD102 /* ml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ml; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C893EAE329CE01783B70D /* gl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gl; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8942D747B2B8E3A2BB53 /* da */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = da; path = Localizable.strings; sourceTree = "<group>"; };
@@ -499,6 +505,7 @@
BF0C8D7DE89E594FEE6DA692 /* ta */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ta; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8D87C20B7308B16498B6 /* et */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = et; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8D9E024876AD9A92BE8D /* sr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sr; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomRecorderControlTestable.swift; sourceTree = "<group>"; };
BF0C8DCAC388B07C369E9771 /* test-base.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "test-base.xcconfig"; sourceTree = "<group>"; };
BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpacesEvents.swift; sourceTree = "<group>"; };
BF0C8DF173A246A0E0C30649 /* override_tags_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_tags_on_poeditor.ts; sourceTree = "<group>"; };
@@ -1039,6 +1046,8 @@
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */,
BF0C8AE65DDFD6359BE12CB3 /* resources */,
BF0C8308B436837FA3EDED86 /* CustomRecorderControlTests.swift */,
BF0C89231FD53C4D55E3CF9A /* Helpers.swift */,
);
path = "unit-tests";
sourceTree = "<group>";
@@ -1430,6 +1439,7 @@
1C9617C5839903D752070CF2 /* ImageTextButtonView.swift */,
BF0C896FD4907CD85D62C124 /* WindowlessAppIndicator.swift */,
BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */,
BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */,
);
path = "generic-components";
sourceTree = "<group>";
@@ -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;
@@ -23,7 +23,7 @@
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
+4
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
}
}
}
@@ -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..<Preferences.holdShortcut.count).contains {
if let holdShortcut = ControlsTab.shortcuts[Preferences.indexToName("holdShortcut", $0)] {
return (holdShortcut.shortcut.carbonModifierFlags | modifiers1) == (holdShortcut.shortcut.carbonModifierFlags | modifiers2)
}
return false
}
}
}
enum ShortcutAcceptance: Equatable {
case accepted
case modifiersOnlyButContainsKeycode
case conflictWithExistingShortcut(shortcutAlreadyAssigned: String)
case reservedByMacos(shortcutUsingEscape: String)
static func == (lhs: ShortcutAcceptance, rhs: ShortcutAcceptance) -> 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
}
}
}
@@ -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..<Preferences.holdShortcut.count).contains {
if let holdShortcut = shortcuts[Preferences.indexToName("holdShortcut", $0)] {
return (holdShortcut.shortcut.carbonModifierFlags | modifiers1) == (holdShortcut.shortcut.carbonModifierFlags | modifiers2)
}
return false
}
}
static func toggleNativeCommandTabIfNeeded() {
let nativeHotkeys: [CGSSymbolicHotKey: (Shortcut) -> 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 }
@@ -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)
@@ -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
}
}
+11
View File
@@ -0,0 +1,11 @@
import XCTest
func XCTAssertArraysEqualUnordered<T: Comparable>(
_ array1: [T],
_ array2: [T],
_ message: @autoclosure () -> String = "",
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertEqual(array1.sorted(), array2.sorted(), message(), file: file, line: line)
}
+3 -1
View File
@@ -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))