From 7d3d1f54f3dec832edfd220c661878ff151c7d2f Mon Sep 17 00:00:00 2001 From: Yi Hyunjoon Date: Tue, 20 Jan 2026 16:03:52 +0900 Subject: [PATCH] feat: add option to trigger shortcuts on key press Add "Trigger on Key Press" option in Hot Keys settings that allows shortcuts to trigger immediately when keys are pressed instead of when released. --- Input Source Pro/Models/PreferencesVM.swift | 4 ++ .../Resources/en.lproj/Localizable.strings | 3 ++ .../Resources/ja.lproj/Localizable.strings | 3 ++ .../Resources/ko.lproj/Localizable.strings | 3 ++ .../zh-Hans.lproj/Localizable.strings | 3 ++ .../zh-Hant.lproj/Localizable.strings | 3 ++ .../UI/Screens/KeyboardsSettingsView.swift | 32 +++++++++++- .../Utilities/ShortcutTrigger.swift | 49 ++++++++++++++----- 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/Input Source Pro/Models/PreferencesVM.swift b/Input Source Pro/Models/PreferencesVM.swift index e58c7ab..8a54e7b 100644 --- a/Input Source Pro/Models/PreferencesVM.swift +++ b/Input Source Pro/Models/PreferencesVM.swift @@ -254,6 +254,7 @@ struct Preferences { static let singleModifierTriggerGroupMapping = "singleModifierTriggerGroupMapping" static let singleModifierInputSourceMapping = "singleModifierInputSourceMapping" static let singleModifierGroupMapping = "singleModifierGroupMapping" + static let isShortcutTriggerOnKeyDown = "isShortcutTriggerOnKeyDown" static let isAutoAppearanceMode = "isAutoAppearanceMode" static let appearanceMode = "appearanceMode" @@ -347,6 +348,9 @@ struct Preferences { @CodableUserDefault(Preferences.Key.singleModifierGroupMapping) var singleModifierGroupMapping = [String: ModifierCombo]() + @UserDefault(Preferences.Key.isShortcutTriggerOnKeyDown) + var isShortcutTriggerOnKeyDown = false + // MARK: - App Rules @UserDefault(Preferences.Key.systemWideDefaultKeyboardId) diff --git a/Input Source Pro/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 0844bec..9d57019 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -215,3 +215,6 @@ "Open Input Monitoring Settings" = "Open Input Monitoring Settings"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list."; "Disabled until permissions are granted" = "Disabled until permissions are granted"; + +"Trigger on Key Press" = "Trigger on Key Press"; +"Trigger on Key Press Description" = "When enabled, shortcuts trigger immediately when keys are pressed. When disabled, shortcuts trigger when keys are released."; diff --git a/Input Source Pro/Resources/ja.lproj/Localizable.strings b/Input Source Pro/Resources/ja.lproj/Localizable.strings index e087346..b715340 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -215,3 +215,6 @@ "Open Input Monitoring Settings" = "入力監視設定を開く"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "入力監視設定を開き、「+」ボタンをクリックして一覧に「Input Source Pro」を追加してください。"; "Disabled until permissions are granted" = "権限が付与されるまで無効"; + +"Trigger on Key Press" = "キーを押したときにトリガー"; +"Trigger on Key Press Description" = "有効にすると、キーを押した瞬間にショートカットがトリガーされます。無効にすると、キーを離したときにトリガーされます。"; diff --git a/Input Source Pro/Resources/ko.lproj/Localizable.strings b/Input Source Pro/Resources/ko.lproj/Localizable.strings index 05c4bd5..791d5ec 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -215,3 +215,6 @@ "Open Input Monitoring Settings" = "입력 모니터링 설정 열기"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "입력 모니터링 설정을 열고 \"+\" 버튼을 클릭한 다음 목록에 \"Input Source Pro\"를 추가하세요."; "Disabled until permissions are granted" = "권한이 허용될 때까지 비활성화됨"; + +"Trigger on Key Press" = "키를 누를 때 트리거"; +"Trigger on Key Press Description" = "활성화하면 키를 누르는 즉시 단축키가 트리거됩니다. 비활성화하면 키를 뗄 때 단축키가 트리거됩니다."; diff --git a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings index e61b52f..b3436b1 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -217,3 +217,6 @@ "Open Input Monitoring Settings" = "打开「输入监控」设置"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "打开「输入监控」设置,点击“+”按钮并将“Input Source Pro”添加到列表中。"; "Disabled until permissions are granted" = "权限授予前将被禁用"; + +"Trigger on Key Press" = "按下时触发"; +"Trigger on Key Press Description" = "启用时,按下按键立即触发快捷键。禁用时,松开按键时触发快捷键。"; diff --git a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings index e1dfe3b..7808c69 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -217,3 +217,6 @@ "Open Input Monitoring Settings" = "打開「輸入監控」設定"; "Open Input Monitoring Settings, click the \"+\" button and add \"Input Source Pro\" to the list." = "打開「輸入監控」設定,點擊「+」按鈕並將「Input Source Pro」加入列表。"; "Disabled until permissions are granted" = "權限授予前將被停用"; + +"Trigger on Key Press" = "按下時觸發"; +"Trigger on Key Press Description" = "啟用時,按下按鍵立即觸發快捷鍵。停用時,放開按鍵時觸發快捷鍵。"; diff --git a/Input Source Pro/UI/Screens/KeyboardsSettingsView.swift b/Input Source Pro/UI/Screens/KeyboardsSettingsView.swift index be5aad4..e74ca5c 100644 --- a/Input Source Pro/UI/Screens/KeyboardsSettingsView.swift +++ b/Input Source Pro/UI/Screens/KeyboardsSettingsView.swift @@ -51,7 +51,8 @@ struct KeyboardsSettingsView: View { if hasSingleModifierShortcuts && (needsAccessibilityPermission || needsInputMonitoringPermission) { permissionWarningSection } - + + shortcutOptionsSection normalSection groupSection AddSwitchingGroupButton(onSelect: preferencesVM.addHotKeyGroup) @@ -59,6 +60,35 @@ struct KeyboardsSettingsView: View { .padding() } .background(NSColor.background1.color) + .toggleStyle(.switch) + } + + @ViewBuilder + var shortcutOptionsSection: some View { + let isShortcutTriggerOnKeyDown = Binding( + get: { preferencesVM.preferences.isShortcutTriggerOnKeyDown }, + set: { newValue in + preferencesVM.update { $0.isShortcutTriggerOnKeyDown = newValue } + indicatorVM.refreshShortcut() + } + ) + + SettingsSection(title: "") { + HStack(alignment: .firstTextBaseline) { + Toggle("", + isOn: isShortcutTriggerOnKeyDown + ) + VStack(alignment: .leading, spacing: 6) { + Text("Trigger on Key Press".i18n()) + Text(.init("Trigger on Key Press Description".i18n())) + .font(.system(size: 12)) + .opacity(0.8) + } + Spacer() + } + .padding() + } + .padding(.bottom) } @ViewBuilder diff --git a/Input Source Pro/Utilities/ShortcutTrigger.swift b/Input Source Pro/Utilities/ShortcutTrigger.swift index ec87172..e338783 100644 --- a/Input Source Pro/Utilities/ShortcutTrigger.swift +++ b/Input Source Pro/Utilities/ShortcutTrigger.swift @@ -340,9 +340,16 @@ final class ShortcutTriggerManager { } private func registerKeyboardShortcuts(_ bindings: [ShortcutBinding]) { + let triggerOnKeyDown = preferencesVM.preferences.isShortcutTriggerOnKeyDown for binding in bindings { - KeyboardShortcuts.onKeyUp(for: .init(binding.id)) { - binding.onTrigger() + if triggerOnKeyDown { + KeyboardShortcuts.onKeyDown(for: .init(binding.id)) { + binding.onTrigger() + } + } else { + KeyboardShortcuts.onKeyUp(for: .init(binding.id)) { + binding.onTrigger() + } } } } @@ -484,6 +491,7 @@ final class ShortcutTriggerManager { flags.remove(.capsLock) let isKeyDown = flags.contains(key.modifierFlag) + let triggerOnKeyDown = preferencesVM.preferences.isShortcutTriggerOnKeyDown if isKeyDown { lastKeyDownTimestamps[event.keyCode] = event.timestamp @@ -495,6 +503,10 @@ final class ShortcutTriggerManager { pressedModifiers[key] = event.timestamp } updateComboState(pressedKeys: Set(pressedModifiers.keys), timestamp: event.timestamp) + + if triggerOnKeyDown { + triggerCompletedCombos(at: event.timestamp, isKeyDown: true) + } } else { // Modifier key released guard pressedModifiers.removeValue(forKey: key) != nil else { return } @@ -507,7 +519,9 @@ final class ShortcutTriggerManager { return } - triggerCompletedCombos(at: event.timestamp) + if !triggerOnKeyDown { + triggerCompletedCombos(at: event.timestamp) + } comboInvalidated.removeAll() comboCompleted.removeAll() comboPressTimestamps.removeAll() @@ -552,15 +566,18 @@ final class ShortcutTriggerManager { } } - private func triggerCompletedCombos(at timestamp: TimeInterval) { + private func triggerCompletedCombos(at timestamp: TimeInterval, isKeyDown: Bool = false) { for combo in comboCompleted { guard !comboInvalidated.contains(combo) else { continue } guard let binding = currentBindingsByCombo[combo] else { continue } - let pressTimestamp = comboPressTimestamps[combo] ?? timestamp - let holdDuration = timestamp - pressTimestamp - if holdDuration > maxHoldDuration { - continue + // Only check hold duration on key up + if !isKeyDown { + let pressTimestamp = comboPressTimestamps[combo] ?? timestamp + let holdDuration = timestamp - pressTimestamp + if holdDuration > maxHoldDuration { + continue + } } if didPressOtherKeyRecently(before: timestamp, excluding: combo.keys) { @@ -574,12 +591,17 @@ final class ShortcutTriggerManager { ? .singlePress : binding.singleModifierTrigger - handleSingleModifierTrigger( + let didTrigger = handleSingleModifierTrigger( combo: combo, timestamp: timestamp, trigger: effectiveTrigger, action: binding.onTrigger ) + + // Invalidate the combo after triggering on key down to prevent double-triggering + if isKeyDown && didTrigger { + comboInvalidated.insert(combo) + } } } @@ -597,28 +619,33 @@ final class ShortcutTriggerManager { return timestamp - lastOtherKeyTimestamp <= otherKeyPressSuppressInterval } + /// Returns true if action was triggered + @discardableResult private func handleSingleModifierTrigger( combo: ModifierCombo, timestamp: TimeInterval, trigger: SingleModifierTrigger, action: () -> Void - ) { + ) -> Bool { guard let key = combo.singleKey else { action() - return + return true } switch trigger { case .singlePress: action() + return true case .doublePress: if let lastTap = modifierTapTimestamps[key], timestamp - lastTap <= doublePressInterval { modifierTapTimestamps.removeValue(forKey: key) action() + return true } else { modifierTapTimestamps[key] = timestamp + return false } } }