diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 1617291f..87f4c28e 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -152,8 +152,10 @@ final class KeybindTrigger { private func performKeybind(keyCode: CGKeyCode, type: CGEventType, isARepeat: Bool, flags: CGEventFlags, isLoopOpen: Bool) -> PerformKeybindResult { let flagKeys = sideDependentTriggerKey ? flags.keyCodes : flags.keyCodes.baseModifiers let allPressedKeys: Set = pressedKeys.union(flagKeys) + let actionKeys: Set = Set(allPressedKeys.subtracting(triggerKey).map(\.baseModifier)) let containsTrigger = allPressedKeys.isSuperset(of: triggerKey) + let allPressedKeysBaseModifiers: Set = Set(allPressedKeys.map(\.baseModifier)) if isLoopOpen { if pressedKeys.contains(.kVK_Escape) { @@ -175,10 +177,9 @@ final class KeybindTrigger { if containsTrigger { // Try an match directly with the action keys first, then fallback to just the key code. // This prevents failures when the user is tapping the keys in rapid succession. - let initalMatch = windowActionCache.actionsByKeybind[actionKeys] - let fallbackMatch = windowActionCache.actionsByKeybind[[keyCode]] + let match = windowActionCache.actionsByKeybind[actionKeys] ?? windowActionCache.actionsByKeybind[[keyCode]] - if let action = initalMatch ?? fallbackMatch { + if let action = match { if !isARepeat || action.canRepeat { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } @@ -196,6 +197,12 @@ final class KeybindTrigger { ) return .opening } + } else if let bypassedAction = windowActionCache.bypassedActionsByKeybind[allPressedKeysBaseModifiers] { + if !isARepeat || bypassedAction.canRepeat { + openLoop(startingAction: bypassedAction, overrideExistingTriggerDelayTimerAction: true) + } + + return checkIfLoopOpen() ? .consume : .opening } else { if allPressedKeys.isEmpty { doubleClickTimer.handleKeyUp() diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 34db9005..89113a20 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3302,6 +3302,9 @@ } } } + }, + "Clear Keybind" : { + }, "Close" : { "comment" : "Label for a button that closes a modal window", @@ -12469,6 +12472,10 @@ } } }, + "Link Trigger Key" : { + "comment" : "A button that links a trigger key to an action's keybind.", + "isCommentAutoGenerated" : true + }, "Locked icon alert title" : { "localizations" : { "ar" : { @@ -21513,6 +21520,10 @@ } } }, + "Please include at least one modifier key." : { + "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", + "isCommentAutoGenerated" : true + }, "Position" : { "localizations" : { "ar" : { @@ -30016,6 +30027,10 @@ } } }, + "Unlink Trigger Key" : { + "comment" : "A menu item that removes the trigger key from the keybind.", + "isCommentAutoGenerated" : true + }, "Unstash" : { "comment" : "Window action", "localizations" : { @@ -32000,87 +32015,9 @@ } } }, - "You can only use up to %lld keys in a keybind, including the trigger key." : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يمكنك استخدام حتى %lld مفاتيح في ربط المفتاح، بما في ذلك مفتاح التشغيل." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Du kannst in einem Tastenkürzel nur bis zu %lld Tasten verwenden, einschließlich der Aktivierungstaste." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You can only use up to %lld keys in a keybind, including the trigger key." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Solo se pueden usar hasta %lld teclas en una combinación, incluyendo la tecla de activación" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous pouvez utiliser un maximum de %lld touches par raccourci, touches d'activation incluses." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Puoi usare un massimo di %lld tasti nelle scorciatoie, compresi i tasti di attivazione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キー設定では、トリガーキーも含めて %lld キーまで使えます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "트리거 키를 포함하여 키바인드에는 최대 %lld 개의 키만 사용할 수 있습니다." - } - }, - "nl-BE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je kunt maximaal %lld toetsen gebruiken in een toetscombinatie, inclusief de activatie knop." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Você pode usar até %lld teclas em um atalho, incluindo a tecla de gatilho." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вы можете использовать до %lld клавиш в этом сочетании, включая клавишу активации." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在一个快捷键中,你最多只能使用%lld个按键(包括触发键)。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "在一個按鍵綁定中,你最多只能使用%lld個按鍵(包括觸發鍵)。" - } - } - } + "You can only use up to %lld keys in a keybind." : { + "comment" : "An error message displayed when a user tries to create a keybind with more than the maximum number of keys allowed. The placeholder is replaced with the actual number of keys allowed.", + "isCommentAutoGenerated" : true }, "You can only use up to %lld keys in your trigger key." : { "localizations" : { @@ -32412,5 +32349,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift index a9862ef8..ebf99438 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift @@ -21,6 +21,7 @@ struct Keycorder: View { @Binding private var validCurrentKeybind: Set @State private var selectionKeybind: Set @Binding private var direction: WindowDirection + @Binding private var bypassTriggerKey: Bool? @State private var eventMonitor: LocalEventMonitor? @State private var shouldShake: Bool = false @@ -33,6 +34,7 @@ struct Keycorder: View { init(_ keybind: Binding) { self._validCurrentKeybind = keybind.keybind self._direction = keybind.direction + self._bypassTriggerKey = keybind.bypassTriggerKey self._selectionKeybind = State(initialValue: keybind.wrappedValue.keybind) } @@ -82,7 +84,7 @@ struct Keycorder: View { isHovering = hovering } .onChange(of: model.currentEventMonitor) { _ in - if model.currentEventMonitor != eventMonitor { + if let eventMonitor, model.currentEventMonitor != eventMonitor { finishedObservingKeys(wasForced: true) } } @@ -104,6 +106,9 @@ struct Keycorder: View { func startObservingKeys() { selectionKeybind = [] isActive = true + + LoopManager.shared.keybindTrigger.stop() + eventMonitor = LocalEventMonitor(events: [.keyDown, .keyUp]) { event in // Handle regular key presses first if event.type == .keyDown, !event.isARepeat { @@ -133,61 +138,43 @@ struct Keycorder: View { let currentKeys = selectionKeybind + [event.keyCode] .map { $0.baseKey(flags: event.modifierFlags) } - var flags = CGEventFlags(cocoaFlags: event.modifierFlags) + var flags = CGEventFlags( + cocoaFlags: event.modifierFlags + .intersection(.deviceIndependentFlagsMask) // Prevents right/left dependence + ) if event.keyCode.isFnSpecialKey { flags.remove(.maskSecondaryFn) } - // Filter out trigger keys from flags - let validModifiers = flags.keyCodes.map(\.baseModifier).filter { - !Defaults[.triggerKey] - .map(\.baseModifier) - .contains($0) + let validModifiers = if bypassTriggerKey == true { + flags.keyCodes + } else { + flags.keyCodes.filter { + !Defaults[.triggerKey] + .map(\.baseModifier) + .contains($0) + } } let finalKeys = Set(currentKeys + validModifiers) + shouldError = false + /// Make sure we don't go over the key limit - guard finalKeys.count < keyLimit else { - errorMessage = "You can only use up to \(keyLimit) keys in a keybind, including the trigger key." - shouldShake.toggle() + guard finalKeys.count <= keyLimit else { + errorMessage = "You can only use up to \(keyLimit) keys in a keybind." + shake() shouldError = true return } - shouldError = false selectionKeybind = finalKeys } func finishedObservingKeys(wasForced: Bool = false) { isActive = false - var willSet = !wasForced - - if validCurrentKeybind == selectionKeybind { - willSet = false - } - - if willSet { - for keybind in Defaults[.keybinds] where - keybind.keybind == selectionKeybind { - willSet = false - - if let name = keybind.name, !name.isEmpty { - self.errorMessage = "That keybind is already being used by \(name)." - } else if keybind.direction == .custom { - self.errorMessage = "That keybind is already being used by another custom keybind." - } else if keybind.direction == .stash { - self.errorMessage = "That keybind is already being used by another stash keybind." - } else { - self.errorMessage = "That keybind is already being used by \(keybind.direction.name.lowercased())." - } - - self.shouldShake.toggle() - self.shouldError = true - break - } - } + let willSet = !wasForced && checkValidKeybindConditions() if willSet { // Set the valid keybind to the current selected one @@ -199,5 +186,56 @@ struct Keycorder: View { eventMonitor?.stop() eventMonitor = nil + + LoopManager.shared.keybindTrigger.start() + } + + private func checkValidKeybindConditions() -> Bool { + if validCurrentKeybind == selectionKeybind { + return false + } + + // Validate keybind requirements when in bypass mode + if bypassTriggerKey == true, + selectionKeybind.filter(\.isModifier).isEmpty { + errorMessage = "Please include at least one modifier key." + shake() + shouldError = true + return false + } + + let effectiveSelection = bypassTriggerKey == true + ? selectionKeybind + : triggerKey.union(selectionKeybind) + + for keybind in Defaults[.keybinds] { + let effectiveExisting = keybind.bypassTriggerKey == true + ? keybind.keybind + : triggerKey.union(keybind.keybind) + + guard effectiveSelection == effectiveExisting else { continue } + + if let name = keybind.name, !name.isEmpty { + errorMessage = "That keybind is already being used by \(name)." + } else if keybind.direction == .custom { + errorMessage = "That keybind is already being used by another custom keybind." + } else if keybind.direction == .stash { + errorMessage = "That keybind is already being used by another stash keybind." + } else { + errorMessage = "That keybind is already being used by \(keybind.direction.name.lowercased())." + } + + shake() + shouldError = true + return false + } + + return true + } + + private func shake() { + Task { + shouldShake.toggle() + } } } diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift index b25d451f..ea7eae33 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift @@ -96,7 +96,7 @@ struct TriggerKeycorder: View { isHovering = hovering } .onChange(of: model.currentEventMonitor) { _ in - if model.currentEventMonitor != eventMonitor { + if let eventMonitor, model.currentEventMonitor != eventMonitor { finishedObservingKeys(wasForced: true) } } @@ -129,7 +129,6 @@ struct TriggerKeycorder: View { selectionKey = [] isActive = true - // So that if doesn't interfere with the key detection here LoopManager.shared.keybindTrigger.stop() eventMonitor = LocalEventMonitor(events: [.keyDown, .flagsChanged]) { event in @@ -148,7 +147,7 @@ struct TriggerKeycorder: View { } if !keycodes.isEmpty, selectionKey.isEmpty { - shouldShake.toggle() + shake() } return nil @@ -163,7 +162,7 @@ struct TriggerKeycorder: View { if selectionKey.count > keyLimit { willSet = false - shouldShake.toggle() + shake() tooManyKeysPopup = true } @@ -182,6 +181,12 @@ struct TriggerKeycorder: View { LoopManager.shared.keybindTrigger.start() } + + private func shake() { + Task { + shouldShake.toggle() + } + } } struct TriggerKeycorderKeyView: View { diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index e0fdebab..3f4da81b 100644 --- a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift @@ -32,8 +32,21 @@ struct KeybindItemView: View { /// Checks if there are any existing keybinds with the same key combination private var hasDuplicateKeybinds: Bool { - keybinds - .count { $0.keybind == action.keybind } > 1 + guard !action.keybind.isEmpty else { + return false + } + + let effectiveKeybind = action.bypassTriggerKey == true + ? action.keybind + : triggerKey.union(action.keybind) + + return keybinds.contains { otherAction in + guard otherAction.id != action.id else { return false } + let otherEffectiveKeybind = otherAction.bypassTriggerKey == true + ? otherAction.keybind + : triggerKey.union(otherAction.keybind) + return effectiveKeybind == otherEffectiveKeybind + } } var body: some View { @@ -44,7 +57,6 @@ struct KeybindItemView: View { keybindCombination .frame(maxWidth: .infinity, alignment: .trailing) } - .padding(.horizontal, 12) .onChange(of: isHovering) { _ in if !isHovering { @@ -148,17 +160,13 @@ struct KeybindItemView: View { .luminarePlateau() } else { HStack(spacing: 6) { - if hasDuplicateKeybinds { - keycorderSection(hasConflicts: true) - .padding(.leading, 4) - .luminarePopover(attachedTo: .topLeading) { - Text("There are other keybinds that conflict with this key combination.") - .padding(6) - } - .luminareTint(overridingWith: .red) - } else { - keycorderSection(hasConflicts: false) - } + keycorderSection() + .padding(.leading, 4) + .luminarePopover(attachedTo: .topLeading, hidden: !hasDuplicateKeybinds) { + Text("There are other keybinds that conflict with this key combination.") + .padding(6) + } + .luminareTint(overridingWith: .red) } .fixedSize() } @@ -166,6 +174,26 @@ struct KeybindItemView: View { .luminareCornerRadius(8) } + // MARK: - Helper Methods + + /// Switches to standard mode (keeps the keybind) + private func restoreStandardMode() { + action.keybind = action.keybind.subtracting(triggerKey) + action.bypassTriggerKey = false + } + + /// Merges trigger key into action key and switches to bypass mode + private func switchToBypassMode() { + action.keybind = triggerKey.union(action.keybind) + action.bypassTriggerKey = true + } + + /// Clears the keybind and switches to standard mode + private func clearKeybind() { + action.keybind = [] + action.bypassTriggerKey = false + } + private func label() -> some View { Button { isDirectionPickerPresented.toggle() @@ -199,23 +227,34 @@ struct KeybindItemView: View { .padding(.leading, -4) } - private func keycorderSection(hasConflicts: Bool) -> some View { + private func keycorderSection() -> some View { HStack(spacing: 6) { - HStack(spacing: 6) { - ForEach(triggerKey.sorted().compactMap(\.modifierSystemImage), id: \.self) { image in - Text("\(Image(systemName: image))") + if action.bypassTriggerKey != true { + HStack(spacing: 6) { + ForEach(triggerKey.sorted().compactMap(\.modifierSystemImage), id: \.self) { image in + Text("\(Image(systemName: image))") + } } - } - .font(.callout) - .padding(6) - .frame(height: 27) - .luminarePlateau() + .font(.callout) + .padding(6) + .frame(height: 27) + .luminarePlateau() - Image(systemName: "plus") - .foregroundStyle(.secondary) + Image(systemName: "plus") + .foregroundStyle(.secondary) + } Keycorder($action) - .opacity(hasConflicts ? 0.5 : 1) + .opacity(hasDuplicateKeybinds || action.keybind.isEmpty ? 0.5 : 1) + } + .contextMenu { + if action.bypassTriggerKey == true { + Button("Link Trigger Key", action: restoreStandardMode) + } else { + Button("Unlink Trigger Key", action: switchToBypassMode) + } + + Button("Clear Keybind", action: clearKeybind) } } } diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index eeda2d4f..8d019a9b 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -42,7 +42,8 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial yPoint: Double? = nil, positionMode: CustomWindowActionPositionMode? = nil, sizeMode: CustomWindowActionSizeMode? = nil, - cycle: [WindowAction]? = nil + cycle: [WindowAction]? = nil, + bypassTriggerKey: Bool? = nil ) { self.id = UUID() self.direction = direction @@ -57,6 +58,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial self.yPoint = yPoint self.sizeMode = sizeMode self.cycle = cycle + self.bypassTriggerKey = bypassTriggerKey } /// Initializes a `WindowAction` with the specified direction and an empty keybind. @@ -94,6 +96,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial // Generic Properties var direction: WindowDirection var keybind: Set + var bypassTriggerKey: Bool? // Custom Keybind Properties var name: String? diff --git a/Loop/Window Management/Window Action/WindowActionCache.swift b/Loop/Window Management/Window Action/WindowActionCache.swift index 790ebf5f..62650101 100644 --- a/Loop/Window Management/Window Action/WindowActionCache.swift +++ b/Loop/Window Management/Window Action/WindowActionCache.swift @@ -13,6 +13,7 @@ import Scribe /// This is called from `KeybindObserver`, to retrieve the user's actions in an efficient manner. final class WindowActionCache { private(set) var actionsByKeybind: [Set: WindowAction] = [:] + private(set) var bypassedActionsByKeybind: [Set: WindowAction] = [:] private(set) var actionsByIdentifier: [UUID: WindowAction] = [:] private var observationTask: Task<(), Never>? @@ -45,26 +46,35 @@ final class WindowActionCache { regenerateActionsByKeybind(from: keybinds) regenerateActionsByIdentifier(from: keybinds) + + Log.info("Regenerated cache; normal: \(actionsByKeybind.count), bypassed: \(bypassedActionsByKeybind.count)", category: .windowActionCache) } private func regenerateActionsByKeybind(from keybinds: [WindowAction]) { let cycleBackwardsOnShiftPressed: Bool = Defaults[.cycleBackwardsOnShiftPressed] + let normalActions = keybinds.filter { $0.bypassTriggerKey != true } + let bypassedActions = keybinds.filter { $0.bypassTriggerKey == true } + + // Normal actions: keybind is action-key only (without trigger key) actionsByKeybind = Dictionary( - keybinds.map { ($0.keybind, $0) }, + normalActions.map { ($0.keybind, $0) }, uniquingKeysWith: { first, _ in first } ) if cycleBackwardsOnShiftPressed { actionsByKeybind.merge( - keybinds + normalActions .filter { $0.direction == .cycle } .map { ($0.keybind.union([.kVK_Shift]), $0) }, uniquingKeysWith: { first, _ in first } ) } - Log.info("Finished regenerating actionsByKeybind", category: .windowActionCache) + bypassedActionsByKeybind = Dictionary( + bypassedActions.map { ($0.keybind, $0) }, + uniquingKeysWith: { first, _ in first } + ) } private func regenerateActionsByIdentifier(from keybinds: [WindowAction]) { @@ -72,7 +82,5 @@ final class WindowActionCache { keybinds.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) - - Log.info("Finished regenerating actionsByIdentifier", category: .windowActionCache) } }