fix: better handle some shortcut conflicts (related to 3059dc23)

This commit is contained in:
lwouis committed 2026-06-04 13:55:22 +02:00
1 parent 83b5ddc091
commit cddc89dd21
30 files changed
+181 -87

No files matched your search

+1 -1
View File
@@ -656,7 +656,7 @@
"Shortcut" = "Shortcut";
/* No comment provided by engineer. */
"Shortcut already assigned to another action: %@" = "Shortcut already assigned to another action: %@";
"Shortcut already assigned to: %@" = "Shortcut already assigned to: %@";
/* No comment provided by engineer. */
"Shortcuts When Active" = "Shortcuts When Active";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "تم استيراد الإعدادات";
"Settings…" = "الإعدادات…";
"Shortcut" = "اختصار";
"Shortcut already assigned to another action: %@" = "الاختصار معيّن بالفعل لإجراء آخر: %@";
"Shortcut already assigned to: %@" = "الاختصار معيّن بالفعل: %@";
"Shortcuts When Active" = "الاختصارات عند النشاط";
"Shortcuts when active…" = "الاختصارات عند النشاط…";
"Show" = "إظهار";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Einstellungen importiert";
"Settings…" = "Einstellungen…";
"Shortcut" = "Tastenkürzel";
"Shortcut already assigned to another action: %@" = "Tastenkürzel ist bereits einer anderen Aktion zugewiesen: %@";
"Shortcut already assigned to: %@" = "Tastenkürzel ist bereits zugewiesen: %@";
"Shortcuts When Active" = "Tastenkürzel bei Aktivität";
"Shortcuts when active…" = "Tastenkürzel bei Aktivität…";
"Show" = "Anzeigen";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Settings imported";
"Settings…" = "Settings…";
"Shortcut" = "Shortcut";
"Shortcut already assigned to another action: %@" = "Shortcut already assigned to another action: %@";
"Shortcut already assigned to: %@" = "Shortcut already assigned to: %@";
"Shortcuts When Active" = "Shortcuts When Active";
"Shortcuts when active…" = "Shortcuts when active…";
"Show" = "Show";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Ajustes importados";
"Settings…" = "Ajustes…";
"Shortcut" = "Atajo";
"Shortcut already assigned to another action: %@" = "Atajo ya asignado a otra acción: %@";
"Shortcut already assigned to: %@" = "Atajo ya asignado a: %@";
"Shortcuts When Active" = "Atajos cuando está activo";
"Shortcuts when active…" = "Atajos cuando está activo…";
"Show" = "Mostrar";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Réglages importés";
"Settings…" = "Réglages…";
"Shortcut" = "Raccourci";
"Shortcut already assigned to another action: %@" = "Raccourci déjà affecté à une autre action : %@";
"Shortcut already assigned to: %@" = "Raccourci déjà affecté à : %@";
"Shortcuts When Active" = "Raccourcis pendant l'utilisation";
"Shortcuts when active…" = "Raccourcis pendant l'utilisation…";
"Show" = "Afficher";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "ההגדרות יובאו";
"Settings…" = "הגדרות…";
"Shortcut" = "קיצור דרך";
"Shortcut already assigned to another action: %@" = "קיצור הדרך כבר משויך לפעולה אחרת: %@";
"Shortcut already assigned to: %@" = "קיצור הדרך כבר משויך: %@";
"Shortcuts When Active" = "קיצורי דרך כאשר פעיל";
"Shortcuts when active…" = "קיצורי דרך כאשר פעיל…";
"Show" = "הצג";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Pengaturan diimpor";
"Settings…" = "Pengaturan…";
"Shortcut" = "Pintasan";
"Shortcut already assigned to another action: %@" = "Pintasan sudah ditetapkan untuk tindakan lain: %@";
"Shortcut already assigned to: %@" = "Pintasan sudah ditetapkan untuk: %@";
"Shortcuts When Active" = "Pintasan Saat Aktif";
"Shortcuts when active…" = "Pintasan saat aktif…";
"Show" = "Tampilkan";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Impostazioni importate";
"Settings…" = "Impostazioni…";
"Shortcut" = "Scorciatoia";
"Shortcut already assigned to another action: %@" = "Scorciatoia già assegnata a un'altra azione: %@";
"Shortcut already assigned to: %@" = "Scorciatoia già assegnata a: %@";
"Shortcuts When Active" = "Scorciatoie quando attivo";
"Shortcuts when active…" = "Scorciatoie quando attivo…";
"Show" = "Mostra";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "設定を読み込みました";
"Settings…" = "設定…";
"Shortcut" = "ショートカット";
"Shortcut already assigned to another action: %@" = "ショートカットはすでに別のアクションに割り当てられています: %@";
"Shortcut already assigned to: %@" = "ショートカットはすでに割り当てられています: %@";
"Shortcuts When Active" = "アクティブ時のショートカット";
"Shortcuts when active…" = "アクティブ時のショートカット…";
"Show" = "表示";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "설정을 가져왔습니다";
"Settings…" = "설정…";
"Shortcut" = "단축키";
"Shortcut already assigned to another action: %@" = "단축키가 이미 다른 동작에 할당되어 있습니다: %@";
"Shortcut already assigned to: %@" = "단축키가 이미 할당되어 있습니다: %@";
"Shortcuts When Active" = "활성 시 단축키";
"Shortcuts when active…" = "활성 시 단축키…";
"Show" = "보이기";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Instellingen geïmporteerd";
"Settings…" = "Instellingen…";
"Shortcut" = "Sneltoets";
"Shortcut already assigned to another action: %@" = "Sneltoets is al toegewezen aan een andere actie: %@";
"Shortcut already assigned to: %@" = "Sneltoets is al toegewezen aan: %@";
"Shortcuts When Active" = "Sneltoetsen wanneer actief";
"Shortcuts when active…" = "Sneltoetsen wanneer actief…";
"Show" = "Toon";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Ustawienia zaimportowane";
"Settings…" = "Ustawienia…";
"Shortcut" = "Skrót";
"Shortcut already assigned to another action: %@" = "Skrót jest już przypisany do innej akcji: %@";
"Shortcut already assigned to: %@" = "Skrót jest już przypisany do: %@";
"Shortcuts When Active" = "Skróty, gdy aktywne";
"Shortcuts when active…" = "Skróty, gdy aktywne…";
"Show" = "Pokaż";
@@ -217,7 +217,7 @@
"Settings imported" = "Ajustes importados";
"Settings…" = "Ajustes…";
"Shortcut" = "Atalho";
"Shortcut already assigned to another action: %@" = "Atalho já atribuído a outra ação: %@";
"Shortcut already assigned to: %@" = "Atalho já atribuído a: %@";
"Shortcuts When Active" = "Atalhos quando ativo";
"Shortcuts when active…" = "Atalhos quando ativo…";
"Show" = "Mostrar";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Настройки импортированы";
"Settings…" = "Настройки…";
"Shortcut" = "Сочетание клавиш";
"Shortcut already assigned to another action: %@" = "Сочетание клавиш уже назначено другому действию: %@";
"Shortcut already assigned to: %@" = "Сочетание клавиш уже назначено: %@";
"Shortcuts When Active" = "Сочетания клавиш при активном переключателе";
"Shortcuts when active…" = "Сочетания клавиш при активном переключателе…";
"Show" = "Показать";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Inställningar importerade";
"Settings…" = "Inställningar…";
"Shortcut" = "Kortkommando";
"Shortcut already assigned to another action: %@" = "Kortkommandot är redan tilldelat en annan åtgärd: %@";
"Shortcut already assigned to: %@" = "Kortkommandot är redan tilldelat: %@";
"Shortcuts When Active" = "Kortkommandon när aktiv";
"Shortcuts when active…" = "Kortkommandon när aktiv…";
"Show" = "Visa";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "นำเข้าการตั้งค่าแล้ว";
"Settings…" = "การตั้งค่า…";
"Shortcut" = "ทางลัด";
"Shortcut already assigned to another action: %@" = "ทางลัดถูกกำหนดให้กับการกระทำอื่นแล้ว: %@";
"Shortcut already assigned to: %@" = "ทางลัดถูกกำหนดไว้แล้ว: %@";
"Shortcuts When Active" = "ทางลัดเมื่อทำงานอยู่";
"Shortcuts when active…" = "ทางลัดเมื่อทำงานอยู่…";
"Show" = "แสดง";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Ayarlar içe aktarıldı";
"Settings…" = "Ayarlar…";
"Shortcut" = "Kısayol";
"Shortcut already assigned to another action: %@" = "Kısayol zaten başka bir eyleme atanmış: %@";
"Shortcut already assigned to: %@" = "Kısayol zaten şuna atanmış: %@";
"Shortcuts When Active" = "Etkinken Kullanılan Kısayollar";
"Shortcuts when active…" = "Etkinken kullanılan kısayollar…";
"Show" = "Göster";
+1 -1
View File
@@ -217,7 +217,7 @@
"Settings imported" = "Đã nhập cài đặt";
"Settings…" = "Cài đặt…";
"Shortcut" = "Phím tắt";
"Shortcut already assigned to another action: %@" = "Phím tắt đã được gán cho một hành động khác: %@";
"Shortcut already assigned to: %@" = "Phím tắt đã được gán cho: %@";
"Shortcuts When Active" = "Phím tắt khi hoạt động";
"Shortcuts when active…" = "Phím tắt khi hoạt động…";
"Show" = "Hiển thị";
@@ -217,7 +217,7 @@
"Settings imported" = "设置已导入";
"Settings…" = "设置…";
"Shortcut" = "快捷键";
"Shortcut already assigned to another action: %@" = "快捷键已分配给其他操作:%@";
"Shortcut already assigned to: %@" = "快捷键已分配给:%@";
"Shortcuts When Active" = "激活时的快捷键";
"Shortcuts when active…" = "激活时的快捷键…";
"Show" = "显示";
@@ -217,7 +217,7 @@
"Settings imported" = "已輸入設定";
"Settings…" = "設定…";
"Shortcut" = "快捷鍵";
"Shortcut already assigned to another action: %@" = "快捷鍵已指派給另一項動作:%@";
"Shortcut already assigned to: %@" = "快捷鍵已指派給:%@";
"Shortcuts When Active" = "作用中時的快捷鍵";
"Shortcuts when active…" = "作用中時的快捷鍵…";
"Show" = "顯示";
@@ -217,7 +217,7 @@
"Settings imported" = "設定已輸入";
"Settings…" = "設定…";
"Shortcut" = "快速鍵";
"Shortcut already assigned to another action: %@" = "快速鍵已指派給其他動作:%@";
"Shortcut already assigned to: %@" = "快速鍵已指派給:%@";
"Shortcuts When Active" = "啟用時的快速鍵";
"Shortcuts when active…" = "啟用時的快速鍵…";
"Show" = "顯示";
+2
View File
@@ -220,6 +220,8 @@ class Preferences {
static var holdShortcut = ["⌥", "⌥", "⌥"]
static let minShortcutCount = 1
static let maxShortcutCount = 9
// Matches `defaultShortcuts` (3 hold slots: holdShortcut / holdShortcut2 / holdShortcut3).
static var shortcutCount = 3
static func indexToName(_ baseName: String, _ index: Int) -> String {
return baseName + (index == 0 ? "" : String(index + 1))
+25 -39
View File
@@ -5,7 +5,10 @@ import ShortcutRecorder
class CustomRecorderControl: RecorderControl {
static let allowedModifiers = NSEvent.ModifierFlags(arrayLiteral: [.command, .control, .option, .shift])
var clearable: Bool!
var id: String!
/// The preference key this recorder edits. Derived from `identifier` (set in `init` and re-aimed
/// by `TriggerBinding.bind` as the recycled editor switches shortcuts) so it can never drift out
/// of sync with the key the write path (`controlWasChanged`) and the conflict detector key off.
var id: String { identifier!.rawValue }
convenience init(_ shortcutString: String, _ clearable: Bool, _ id: String) {
self.init(Shortcut(keyEquivalent: shortcutString), clearable, id)
@@ -15,7 +18,7 @@ class CustomRecorderControl: RecorderControl {
self.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
self.clearable = clearable
self.id = id
identifier = NSUserInterfaceItemIdentifier(id)
delegate = self
allowsEscapeToCancelRecording = false
allowsDeleteToClearShortcutAndEndRecording = false
@@ -41,49 +44,39 @@ class CustomRecorderControl: RecorderControl {
set(allowedModifierFlags: CustomRecorderControl.allowedModifiers.subtracting(restrictedModifiers), requiredModifierFlags: [], allowsEmptyModifierFlags: true)
}
/// How to clear the conflicting shortcut when the user picks "Unassign and continue". Resolved
/// from the id alone — arrow/vim are toggled off via their checkboxes; everything else is cleared
/// through `Preferences` by id (so a shortcut that isn't on screen is handled correctly). The
/// human-readable label shown in the dialog comes separately from `ControlsTab.conflictLabel`.
private enum ShortcutConflict {
case arrow
case vim
case regular(control: CustomRecorderControl, label: String)
case unknown
var label: String {
switch self {
case .arrow: return "Arrow keys"
case .vim: return "Vim keys"
case .regular(_, let label): return label
case .unknown: return "an unknown action"
}
}
case regular(id: String)
static func classify(_ id: String) -> ShortcutConflict {
if ["←", "→", "↑", "↓"].contains(id) { return .arrow }
if id.starts(with: "vimCycle") { return .vim }
if let existing = ControlsTab.shortcutControls[id] {
return .regular(control: existing.0, label: existing.1)
}
assertionFailure("conflict id '\(id)' missing from shortcutControls")
return .unknown
return .regular(id: id)
}
}
func alertIfSameShortcutAlreadyAssigned(_ candidateShortcut: Shortcut, _ shortcutAlreadyAssigned: String) {
let conflict = ShortcutConflict.classify(shortcutAlreadyAssigned)
// `conflictLabel` returns nil only for an id with no known action, which can't happen for a
// real detected conflict; keep the prior plain-string fallback (not a new l10n key).
let label = ControlsTab.conflictLabel(shortcutAlreadyAssigned) ?? "an unknown action"
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to another action: %@", comment: ""),
conflict.label.replacingOccurrences(of: " ", with: "\u{00A0}"))
if !id.starts(with: "holdShortcut") {
alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")).setAccessibilityFocused(true)
}
alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to: %@", comment: ""),
label.replacingOccurrences(of: " ", with: "\u{00A0}"))
// Always offer to resolve it, including when editing a Hold: unassigning a conflicting Trigger
// clears its "and press" part (the hold itself is never the thing unassigned).
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") {
cancelButton.setAccessibilityFocused(true)
}
let userChoice = alert.runModal()
guard !id.starts(with: "holdShortcut"), userChoice == .alertFirstButtonReturn else { return }
guard userChoice == .alertFirstButtonReturn else { return }
switch conflict {
case .arrow:
if let cb = ControlsTab.arrowKeysCheckbox {
@@ -97,10 +90,8 @@ class CustomRecorderControl: RecorderControl {
ControlsTab.vimKeysEnabledCallback(cb)
LabelAndControl.controlWasChanged(cb, nil)
}
case .regular(let existingControl, _):
updateShortcut(existingControl, nil, existingControl, shortcutAlreadyAssigned)
case .unknown:
return
case .regular(let conflictingId):
ControlsTab.unassignShortcut(conflictingId)
}
updateShortcut(self, candidateShortcut, self, id)
}
@@ -112,23 +103,18 @@ class CustomRecorderControl: RecorderControl {
}
func alertIfShortcutReservedByMacos(_ candidateShortcut: Shortcut, _ shortcutReservedByMacos: String) {
let existing = ControlsTab.shortcutControls[shortcutReservedByMacos]
if existing == nil {
assertionFailure("reserved-by-macos id '\(shortcutReservedByMacos)' missing from shortcutControls")
}
let label = ControlsTab.conflictLabel(shortcutReservedByMacos) ?? "an unknown action"
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
alert.informativeText = String(format: NSLocalizedString("macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@.", comment: ""), existing?.1 ?? "an unknown action")
alert.informativeText = String(format: NSLocalizedString("macOS reserves ⌘⌥⎋, ⌘⌥⇧⎋, and ⌘⌥⇧⌃⎋ for Force Quit and they cannot be unbound. AltTab cannot use them.\n\nYour change would assign one of these to: %@.", comment: ""), label)
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()
guard userChoice == .alertFirstButtonReturn, id != shortcutReservedByMacos else { return }
if let existing {
updateShortcut(existing.0, nil, existing.0, shortcutReservedByMacos)
}
ControlsTab.unassignShortcut(shortcutReservedByMacos)
updateShortcut(self, candidateShortcut, self, id)
}
+10
View File
@@ -18,6 +18,11 @@ existing AltTab shortcut or a macOS-reserved one.
- A combo **reserved by macOS** is rejected.
- Regression (#5585): a Cmd-based hold shortcut is **no longer blocked** by the macOS 26 Game Overlay
reservation — that specific over-broad rejection was removed.
- The `candidateId` handed in must be a **well-formed bound preference key** (`isWellFormedCandidateId`):
a `holdShortcut`/`nextWindowShortcut` id has to resolve to an in-range shortcut index. The recycled
`ShortcutEditor` regression fed a frozen placeholder (`"nextWindowShortcut0"`, index -1) that matched
nothing and silently returned `.accepted`, so the conflict dialog stopped appearing. A `#if DEBUG`
assert now trips loudly if a malformed id ever reaches the check.
## Test scenarios
@@ -27,9 +32,14 @@ Mirrors `CustomRecorderControlTests.swift` 1:1.
- **testIsShortcutAcceptable_accepted** — a valid key+modifier combo is accepted.
- **testIsShortcutAcceptable_modifiersOnlyButContainsKeycode** — modifiers-only flags but with a real keycode → accepted.
- **testIsShortcutAcceptable_conflictWithExistingShortcut** — collides with an existing shortcut → rejected.
- **testIsShortcutAcceptable_holdChangeStripsOldHoldFromCombinedNextWindow** — changing a shortcut's hold (e.g. ⌘→⌥) so its chord now duplicates another shortcut is detected; the same-index nextWindow is stored COMBINED with the old hold, so the old hold is stripped before applying the new one (else ⌥⌘+Tab is compared instead of ⌥+Tab and the conflict is missed).
- **testIsShortcutAcceptable_reservedByMacos** — a macOS-reserved combo → rejected.
- **testIsShortcutAcceptable_cmdHoldShortcutNoLongerBlockedByGameOverlay** — regression #5585: a Cmd hold shortcut is accepted (Game Overlay no longer blocks it).
### isWellFormedCandidateId
Guards the recycled-`ShortcutEditor` regression where a stale recorder id (id/identifier drift) reached the conflict check and silently suppressed the dialog.
- **testIsWellFormedCandidateId** — hold/next ids must resolve to an in-range index; the `"…Shortcut0"` placeholders (index -1) and out-of-range ids are rejected; static/arrow/vim ids are always well-formed.
### combinedModifiersMatch
Used by the keyboard matcher to recognize a chord whose modifiers are physically split between the configured `holdShortcut` and a local shortcut (e.g. commandShiftTab = ⌘⌥-hold + ⇧).
- **testCombinedModifiersMatchEqualToItself** — a modifier set matches itself (trivial union).
+21 -4
View File
@@ -3,7 +3,19 @@ import Carbon.HIToolbox.Events
import ShortcutRecorder
class CustomRecorderControlTestable {
/// A `holdShortcut`/`nextWindowShortcut` candidate id must resolve to an in-range shortcut index;
/// every other id (the static "when active" shortcuts, arrow, vim) is well-formed by definition.
/// This is the contract the recycled `ShortcutEditor` must uphold: the id handed to the conflict
/// check is the bound preference key, not a stale placeholder. The regression that hid the
/// conflict dialog was exactly a malformed id — the recorder's frozen `"nextWindowShortcut0"`,
/// whose `nameToIndex` is -1 — silently matching nothing and returning `.accepted`.
static func isWellFormedCandidateId(_ id: String) -> Bool {
guard id.hasPrefix("holdShortcut") || id.hasPrefix("nextWindowShortcut") else { return true }
return (0..<Preferences.shortcutCount).contains(Preferences.nameToIndex(id))
}
static func isShortcutAcceptable(_ candidateId: String, _ candidateShortcut: Shortcut) -> ShortcutAcceptance {
assert(isWellFormedCandidateId(candidateId), "malformed candidateId '\(candidateId)' reached the conflict check — recycled editor id/identifier drift?")
if let currentShortcutWithSameId = ControlsTab.shortcuts[candidateId]?.shortcut,
candidateShortcut.carbonKeyCode == currentShortcutWithSameId.carbonKeyCode && candidateShortcut.carbonModifierFlags == currentShortcutWithSameId.carbonModifierFlags {
return .accepted
@@ -32,11 +44,16 @@ class CustomRecorderControlTestable {
&& !(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)) {
// combine the candidate's (new) hold modifiers with a local shortcut, which is stored raw
if !atShortcut.id.starts(with: "holdShortcut") && !atShortcut.id.starts(with: "nextWindowShortcut") {
combos.append((atShortcut.id, Shortcut(code: atShortcut.shortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil)))
// combine with the nextWindowShortcut of the same index. That shortcut is stored COMBINED
// with the CURRENT (old) hold, so strip the old hold before applying the candidate's new
// hold — otherwise changing the hold (e.g. ⌘→⌥) leaks the old modifier (⌥⌘⇥ instead of
// ⌥⇥) and a real conflict is missed. (No-op when nextWindow is stored raw, e.g. in tests.)
} else if atShortcut.id.starts(with: "nextWindowShortcut") && Preferences.nameToIndex(candidateId) == Preferences.nameToIndex(atShortcut.id) {
let oldHoldModifiers = ControlsTab.shortcuts[Preferences.indexToName("holdShortcut", Preferences.nameToIndex(candidateId))]?.shortcut.modifierFlags ?? []
combos.append((atShortcut.id, Shortcut(code: atShortcut.shortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags.subtracting(oldHoldModifiers)], characters: nil, charactersIgnoringModifiers: nil)))
}
// candidate is nextWindowShortcut
} else if candidateId.starts(with: "nextWindowShortcut") {
+43
View File
@@ -27,6 +27,25 @@ final class CustomRecorderControlTests: XCTestCase {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "previousWindowShortcut"))
}
/// Regression: S1 = ⌥+Tab, S2 = ⌘+Tab. Changing S2's *hold* from ⌘ to ⌥ makes it ⌥+Tab, which must
/// conflict with S1. The registry here mirrors PRODUCTION, where `nextWindowShortcut` is stored
/// COMBINED (hold ∪ key) — unlike `defaultShortcuts`, which stores it raw, which is precisely why
/// the existing tests never exercised this. Without stripping S2's current ⌘ hold before applying
/// the new ⌥, the chord is mis-computed as ⌥⌘+Tab and the conflict is silently accepted.
func testIsShortcutAcceptable_holdChangeStripsOldHoldFromCombinedNextWindow() {
ControlsTab.shortcuts = [
"holdShortcut": ATShortcut(Shortcut(keyEquivalent: "⌥")!, "holdShortcut", .global, .up, 0),
"nextWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "⌥⇥")!, "nextWindowShortcut", .global, .down),
"holdShortcut2": ATShortcut(Shortcut(keyEquivalent: "⌘")!, "holdShortcut2", .global, .up, 1),
"nextWindowShortcut2": ATShortcut(Shortcut(keyEquivalent: "⌘⇥")!, "nextWindowShortcut2", .global, .down),
]
defer { ControlsTab.shortcuts = ControlsTab.defaultShortcuts }
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut2", Shortcut(keyEquivalent: "⌥")!),
.conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut"))
// The mirror case: changing the hold to something that does NOT collide stays accepted.
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut2", Shortcut(keyEquivalent: "⌃")!), .accepted)
}
func testIsShortcutAcceptable_reservedByMacos() {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⇧")!), .accepted) // ⌘⎋
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⌃⇧")!), .accepted) // ⌘⎋
@@ -74,6 +93,30 @@ final class CustomRecorderControlTests: XCTestCase {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted)
}
// MARK: - isWellFormedCandidateId
/// Regression guard for the conflict dialog that silently stopped appearing under the recycled
/// `ShortcutEditor`: the recorder handed the kernel a frozen placeholder id (`"nextWindowShortcut0"`,
/// whose `nameToIndex` is -1), which matched no shortcut, so `isShortcutAcceptable` returned
/// `.accepted` and no dialog showed. `isWellFormedCandidateId` rejects exactly that shape — and a
/// `#if DEBUG` assert in `isShortcutAcceptable` now trips loudly if such an id ever reaches it.
func testIsWellFormedCandidateId() {
// hold/next ids must resolve to an in-range shortcut index (shortcutCount == 3 in the mock).
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("holdShortcut")) // index 0
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("nextWindowShortcut")) // index 0
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("nextWindowShortcut2")) // index 1
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("holdShortcut3")) // index 2
// The regressing placeholders: trailing "0" → nameToIndex -1 → out of range.
XCTAssertFalse(CustomRecorderControlTestable.isWellFormedCandidateId("nextWindowShortcut0"))
XCTAssertFalse(CustomRecorderControlTestable.isWellFormedCandidateId("holdShortcut0"))
// Beyond shortcutCount is also malformed.
XCTAssertFalse(CustomRecorderControlTestable.isWellFormedCandidateId("nextWindowShortcut4")) // index 3
// Static "when active" / arrow / vim ids are well-formed regardless of any index.
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("cancelShortcut"))
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("←"))
XCTAssertTrue(CustomRecorderControlTestable.isWellFormedCandidateId("vimCycleLeft"))
}
// MARK: - combinedModifiersMatch
// Per the kernel's doc-comment: the function checks whether two modifier sets match once each is
@@ -52,6 +52,21 @@ class ControlsTab {
"focusWindowShortcut", "previousWindowShortcut", "cancelShortcut", "searchShortcut", "lockSearchShortcut",
"closeWindowShortcut", "minDeminWindowShortcut", "toggleFullscreenWindowShortcut", "quitAppShortcut", "hideShowAppShortcut",
]
/// Canonical id → localized label for the always-active ("when active") shortcuts. Single source
/// of truth: `ShortcutsWhenActiveSheet` reads its row titles from here, and `conflictLabel(_:)`
/// uses it to name a conflicting shortcut without needing its (possibly-unbuilt) sheet control.
static let staticShortcutLabels = [
"focusWindowShortcut": NSLocalizedString("Focus selected window", comment: ""),
"previousWindowShortcut": NSLocalizedString("Select previous window", comment: ""),
"cancelShortcut": NSLocalizedString("Cancel", comment: ""),
"searchShortcut": NSLocalizedString("Search", comment: ""),
"lockSearchShortcut": NSLocalizedString("Lock search", comment: ""),
"closeWindowShortcut": NSLocalizedString("Close window", comment: ""),
"minDeminWindowShortcut": NSLocalizedString("Minimize/Deminimize window", comment: ""),
"toggleFullscreenWindowShortcut": NSLocalizedString("Fullscreen/Defullscreen window", comment: ""),
"quitAppShortcut": NSLocalizedString("Quit app", comment: ""),
"hideShowAppShortcut": NSLocalizedString("Hide/Show app", comment: ""),
]
private static let removableShortcutPreferences = [
"holdShortcut", "nextWindowShortcut",
"appsToShow", "spacesToShow", "screensToShow",
@@ -916,17 +931,37 @@ class ControlsTab {
return true
}
private static func conflictLabel(_ controlId: String) -> String? {
if let shortcutControl = shortcutControls[controlId] {
return shortcutControl.1
/// Human-readable label for the action bound to `id`, resolved purely from the model — the id's
/// shape plus `staticShortcutLabels` — NOT from `shortcutControls`. This is what lets the conflict
/// dialog name a shortcut that isn't currently displayed in the recycled editor (which keeps only
/// the on-screen shortcut in `shortcutControls`). Returns nil for ids with no known action.
///
/// A numbered shortcut's hold and "and press" both belong to that shortcut's Trigger, so they
/// resolve to e.g. "Shortcut 2 - Trigger" — naming WHICH shortcut, since "Select next window"
/// alone doesn't disambiguate when several shortcuts exist.
static func conflictLabel(_ id: String) -> String? {
if arrowKeys.contains(id) { return NSLocalizedString("Arrow keys", comment: "") }
if vimKeyActions.values.contains(id) { return NSLocalizedString("Vim keys", comment: "") }
if id.hasPrefix("holdShortcut") || id.hasPrefix("nextWindowShortcut") {
return shortcutTitle(Preferences.nameToIndex(id)) + " - " + ShortcutEditor.triggerLabel
}
if arrowKeys.contains(controlId) {
return NSLocalizedString("Arrow keys", comment: "")
}
if vimKeyActions.values.contains(controlId) {
return NSLocalizedString("Vim keys", comment: "")
}
return nil
return staticShortcutLabels[id]
}
/// Clear the shortcut bound to `id` and let the normal preference-change pipeline reconcile the
/// registry and UI. Used by the conflict dialog's "Unassign existing shortcut and continue" for a
/// shortcut that may not be on screen, so it goes through `Preferences` (cached UserDefaults)
/// rather than mutating a live recorder. If that recorder happens to be displayed, sync it too.
///
/// For a numbered shortcut's Trigger the hold can't stand alone, so "unassign" clears the "and
/// press" (nextWindowShortcut) part — e.g. ⌥+Tab becomes ⌥+(unassigned) — whether the conflict
/// was reported against the hold or the press.
static func unassignShortcut(_ id: String) {
let keyToClear = (id.hasPrefix("holdShortcut") || id.hasPrefix("nextWindowShortcut"))
? Preferences.indexToName("nextWindowShortcut", Preferences.nameToIndex(id))
: id
Preferences.setShortcut(keyToClear, nil)
shortcutControls[keyToClear]?.0.objectValue = nil
}
private static func shouldClearConflictingShortcuts(_ conflicts: [String], _ messageFormat: String) -> Bool {
@@ -220,8 +220,7 @@ final class TriggerBinding {
private let andPressLabel: NSTextField
private static let holdLabelText = NSLocalizedString("Hold", comment: "")
private static let andPressText = NSLocalizedString("and press", comment: "")
// Not shown as a trigger-row label anymore; kept as the next-window recorder's human-readable
// name in `shortcutControls`, used by ControlsTab's conflict dialogs (see `conflictLabel`).
// The next-window recorder's human-readable name, stored alongside it in `shortcutControls`.
private static let selectNextText = NSLocalizedString("Select next window", comment: "")
private var currentIndex: Int = -1
@@ -2,16 +2,18 @@ import Cocoa
class ShortcutsWhenActiveSheet: SheetWindow {
private static let title = NSLocalizedString("Shortcuts When Active", comment: "")
private static let labelFocus = NSLocalizedString("Focus selected window", comment: "")
private static let labelPrevious = NSLocalizedString("Select previous window", comment: "")
private static let labelCancel = NSLocalizedString("Cancel", comment: "")
private static let labelSearch = NSLocalizedString("Search", comment: "")
private static let labelLockSearch = NSLocalizedString("Lock search", comment: "")
private static let labelClose = NSLocalizedString("Close window", comment: "")
private static let labelMinDemin = NSLocalizedString("Minimize/Deminimize window", comment: "")
private static let labelFullscreen = NSLocalizedString("Fullscreen/Defullscreen window", comment: "")
private static let labelQuit = NSLocalizedString("Quit app", comment: "")
private static let labelHideShow = NSLocalizedString("Hide/Show app", comment: "")
// Row titles come from `ControlsTab.staticShortcutLabels` (single source of truth, also used by
// the conflict dialog to name these actions).
private static let labelFocus = ControlsTab.staticShortcutLabels["focusWindowShortcut"]!
private static let labelPrevious = ControlsTab.staticShortcutLabels["previousWindowShortcut"]!
private static let labelCancel = ControlsTab.staticShortcutLabels["cancelShortcut"]!
private static let labelSearch = ControlsTab.staticShortcutLabels["searchShortcut"]!
private static let labelLockSearch = ControlsTab.staticShortcutLabels["lockSearchShortcut"]!
private static let labelClose = ControlsTab.staticShortcutLabels["closeWindowShortcut"]!
private static let labelMinDemin = ControlsTab.staticShortcutLabels["minDeminWindowShortcut"]!
private static let labelFullscreen = ControlsTab.staticShortcutLabels["toggleFullscreenWindowShortcut"]!
private static let labelQuit = ControlsTab.staticShortcutLabels["quitAppShortcut"]!
private static let labelHideShow = ControlsTab.staticShortcutLabels["hideShowAppShortcut"]!
/// Pre-build search index for the open-button. See `SettingsSearchIndex.sheetSearchableStrings`.
/// `ProBadgeView.proLabel` contributes the "Pro" tag rendered on the search/lock-search rows.