diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3e61b571fda1..89e2e77aa271 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */; }; E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */; }; E3309A0B /* KeyboardShortcutSettingsEqualizeSplitsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A0C /* KeyboardShortcutSettingsEqualizeSplitsTests.swift */; }; + A17110000000000000000001 /* KeyboardShortcutSpaceKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17110000000000000000002 /* KeyboardShortcutSpaceKeyTests.swift */; }; C0DEF0A40000000000000001 /* CmuxConfigContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */; }; D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; }; @@ -102,6 +103,7 @@ A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; }; E8BA79E246754A8B99A0B823 /* ScreenIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */; }; 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */; }; + 76027A12C93B4538BF22C71E /* ShortcutBareStartRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */; }; E5C0F1A0E5C0F1A0E5C0F1A0 /* TerminalFindEscapeRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */; }; 1A8BEE693C9E4C3190CB7F20 /* MenuBarExtraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */; }; 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C05000000000000000001 /* MainWindowFocusController.swift */; }; @@ -282,6 +284,7 @@ F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */; }; F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */; }; F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; @@ -416,6 +419,7 @@ C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateRenameShortcutContextTests.swift; sourceTree = ""; }; C34670020000000000000002 /* KeyboardShortcutContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutContextTests.swift; sourceTree = ""; }; E3309A0C /* KeyboardShortcutSettingsEqualizeSplitsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsEqualizeSplitsTests.swift; sourceTree = ""; }; + A17110000000000000000002 /* KeyboardShortcutSpaceKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSpaceKeyTests.swift; sourceTree = ""; }; 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; @@ -457,6 +461,7 @@ 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxCLIPathInstaller.swift; sourceTree = ""; }; 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ScreenIdentity.swift; sourceTree = ""; }; B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutRoutingSupport.swift; sourceTree = ""; }; + AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutBareStartRouting.swift; sourceTree = ""; }; E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalFindEscapeRouting.swift; sourceTree = ""; }; C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MenuBarExtraController.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; @@ -645,6 +650,7 @@ F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; + 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBareSpaceShortcutRoutingTests.swift; sourceTree = ""; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = ""; }; F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutUnbindingTests.swift; sourceTree = ""; }; F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; @@ -848,6 +854,7 @@ 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */, 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */, B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */, + AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */, E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */, C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */, A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */, @@ -1023,6 +1030,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */, C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */, E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */, F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */, @@ -1051,6 +1059,7 @@ E3337002E3337002E3337002 /* KeyboardShortcutSettingsFileStoreStartupTests.swift */, C34670020000000000000002 /* KeyboardShortcutContextTests.swift */, E3309A0C /* KeyboardShortcutSettingsEqualizeSplitsTests.swift */, + A17110000000000000000002 /* KeyboardShortcutSpaceKeyTests.swift */, 1A1B2C3D4E5F607180000004 /* WorkspacePromptSubmitTests.swift */, C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, @@ -1350,6 +1359,7 @@ A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */, E8BA79E246754A8B99A0B823 /* ScreenIdentity.swift in Sources */, 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */, + 76027A12C93B4538BF22C71E /* ShortcutBareStartRouting.swift in Sources */, E5C0F1A0E5C0F1A0E5C0F1A0 /* TerminalFindEscapeRouting.swift in Sources */, 1A8BEE693C9E4C3190CB7F20 /* MenuBarExtraController.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, @@ -1562,6 +1572,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */, C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */, E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */, F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */, @@ -1589,6 +1600,7 @@ E3337001E3337001E3337001 /* KeyboardShortcutSettingsFileStoreStartupTests.swift in Sources */, C34670020000000000000001 /* KeyboardShortcutContextTests.swift in Sources */, E3309A0B /* KeyboardShortcutSettingsEqualizeSplitsTests.swift in Sources */, + A17110000000000000000001 /* KeyboardShortcutSpaceKeyTests.swift in Sources */, 1A1B2C3D4E5F607180000003 /* WorkspacePromptSubmitTests.swift in Sources */, C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 741f76393618..a4241f933cc8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -83250,6 +83250,23 @@ } } }, + "shortcut.key.space": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Space" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スペース" + } + } + } + }, "shortcut.pressShortcut.prompt": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/ShortcutBareStartRouting.swift b/Sources/App/ShortcutBareStartRouting.swift new file mode 100644 index 000000000000..ef13b7611482 --- /dev/null +++ b/Sources/App/ShortcutBareStartRouting.swift @@ -0,0 +1,81 @@ +import AppKit +import Foundation + +enum KeyboardShortcutBareStartCache { + private static var configuredKeys: Set? + private static var observer: NSObjectProtocol? + + static func hasConfiguredBareShortcutStart(key: String) -> Bool { + installObserverIfNeeded() + + let normalizedKey = key.lowercased() + if let configuredKeys { + return configuredKeys.contains(normalizedKey) + } + + let resolvedKeys = Set( + KeyboardShortcutSettings.Action.allCases.compactMap { action -> String? in + guard action != .showHideAllWindows else { return nil } + return KeyboardShortcutSettings.shortcut(for: action).bareShortcutStartKey + } + ) + configuredKeys = resolvedKeys + return resolvedKeys.contains(normalizedKey) + } + + private static func installObserverIfNeeded() { + guard observer == nil else { return } + observer = NotificationCenter.default.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: .main + ) { _ in + configuredKeys = nil + } + } +} + +extension StoredShortcut { + var bareShortcutStartKey: String? { + guard !isUnbound, firstStroke.modifierFlags.isEmpty else { return nil } + return key.lowercased() + } +} + +func bareShortcutFastPathKey(for event: NSEvent) -> String? { + if event.keyCode == 49 { + return "space" + } + + guard event.specialKey != nil, + let stroke = ShortcutStroke.from(event: event, requireModifier: false), + stroke.modifierFlags.isEmpty else { + return nil + } + return stroke.key.lowercased() +} + +extension AppDelegate { + func shouldBypassPlainKeyShortcutRouting( + event: NSEvent, + normalizedFlags: NSEvent.ModifierFlags + ) -> Bool { + guard normalizedFlags.isEmpty, + activeConfiguredShortcutChordPrefixForCurrentEvent == nil else { + return false + } + + guard let bareShortcutKey = bareShortcutFastPathKey(for: event) else { + return true + } + + guard !KeyboardShortcutBareStartCache.hasConfiguredBareShortcutStart(key: bareShortcutKey) else { + return false + } + + let configuredCmuxShortcutContext = preferredMainWindowContextForShortcutRouting(event: event) + return !configuredCmuxShortcutActions(for: configuredCmuxShortcutContext).contains { + $0.shortcut?.bareShortcutStartKey == bareShortcutKey + } + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 105358ae39ac..af1c9572073a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -658,7 +658,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let windowNumber: Int? } private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? - private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? var shortcutEventFocusContextCache: ShortcutEventFocusContextCache? private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] private var ghosttyConfigObserver: NSObjectProtocol? @@ -6616,7 +6616,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { return context } @@ -10861,8 +10861,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Fast path for normal typing and terminal navigation keys (for example Up-arrow // history): after command-palette/notification handling and browser omnibar - // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { + // arrow navigation above, most plain key events have no app-level shortcut behavior. + if shouldBypassPlainKeyShortcutRouting(event: event, normalizedFlags: normalizedFlags) { return false } @@ -12384,7 +12384,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func configuredCmuxShortcutActions( + func configuredCmuxShortcutActions( for context: MainWindowContext? ) -> [CmuxResolvedConfigAction] { context?.cmuxConfigStore?.shortcutActions() ?? [] diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 8a7d1aa9d42f..9caf8e71b92e 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1192,6 +1192,7 @@ struct ShortcutStroke: Equatable, Hashable { switch key { case "\t": return String(localized: "shortcut.key.tab", defaultValue: "Tab") + case "space": return String(localized: "shortcut.key.space", defaultValue: "Space") case "\r": return "↩" case "media.brightnessDown": @@ -1232,6 +1233,8 @@ struct ShortcutStroke: Equatable, Hashable { } var keyEquivalent: KeyEquivalent? { + if key == "space" { return KeyEquivalent(Character(" ")) } + if Self.usesDirectKeyCodeMatching(key) { return nil } @@ -1274,6 +1277,8 @@ struct ShortcutStroke: Equatable, Hashable { } var menuItemKeyEquivalent: String? { + if key == "space" { return " " } + if Self.usesDirectKeyCodeMatching(key) { return nil } @@ -1499,6 +1504,7 @@ struct ShortcutStroke: Equatable, Hashable { case 125: return "↓" // down arrow case 126: return "↑" // up arrow case 48: return "\t" // tab + case 49: return "space" // kVK_Space case 36, 76: return "\r" // return, keypad enter case 33: return "[" // kVK_ANSI_LeftBracket case 30: return "]" // kVK_ANSI_RightBracket @@ -1666,6 +1672,7 @@ struct ShortcutStroke: Equatable, Hashable { case "media.playPause": return 16 case "media.next": return 17 case "media.previous": return 18 + case "space": return 49 case "a": return 0 case "s": return 1 case "d": return 2 @@ -1725,7 +1732,7 @@ struct ShortcutStroke: Equatable, Hashable { } private static func usesDirectKeyCodeMatching(_ key: String) -> Bool { - functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") + key == "space" || functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") } private static func functionKeyDisplayString(for key: String) -> String? { @@ -1796,7 +1803,7 @@ struct ShortcutStroke: Equatable, Hashable { private static let supportedShortcutKeyCodes: [UInt16] = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - 33, 34, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 33, 34, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 123, 124, 125, 126, ] } @@ -2023,12 +2030,12 @@ struct StoredShortcut: Codable, Equatable, Hashable { extension ShortcutStroke { static func parseConfig(_ rawValue: String) -> ShortcutStroke? { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } + guard !rawValue.isEmpty else { return nil } - let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { + let rawParts = rawValue.split(separator: "+", omittingEmptySubsequences: false) + .map(String.init) + let parts = rawParts.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !parts.isEmpty, let lastRawPart = rawParts.last, !lastRawPart.isEmpty else { return nil } @@ -2052,7 +2059,7 @@ extension ShortcutStroke { } } - guard let key = parseConfigKeyToken(lastPart) else { return nil } + guard let key = parseConfigKeyToken(lastRawPart) else { return nil } return ShortcutStroke( key: key, command: command, @@ -2083,7 +2090,12 @@ extension ShortcutStroke { } private static func parseConfigKeyToken(_ rawValue: String) -> String? { - let lowered = rawValue.lowercased() + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return !rawValue.isEmpty && rawValue.allSatisfy { $0 == " " } ? "space" : nil + } + + let lowered = trimmed.lowercased() switch lowered { case "left", "arrowleft", "leftarrow", "←": return "←" @@ -2097,8 +2109,8 @@ extension ShortcutStroke { return "\t" case "return", "enter", "↩": return "\r" - case "space": - return " " + case "space", "spacebar", "": + return "space" case "comma": return "," case "period", "dot": @@ -2166,7 +2178,7 @@ extension StoredShortcut { guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { return nil } - guard !firstStroke.modifierFlags.isEmpty else { return nil } + guard !firstStroke.modifierFlags.isEmpty || firstStroke.key == "space" else { return nil } let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil return StoredShortcut(first: firstStroke, second: secondStroke) } @@ -2180,8 +2192,9 @@ extension StoredShortcut { } private static func isUnboundConfigToken(_ rawValue: String) -> Bool { + if rawValue.isEmpty { return true } let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized.isEmpty || normalized == "none" || normalized == "clear" || normalized == "unbound" + return normalized == "none" || normalized == "clear" || normalized == "unbound" } } diff --git a/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift b/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift new file mode 100644 index 000000000000..09476dd0d71d --- /dev/null +++ b/cmuxTests/AppDelegateBareSpaceShortcutRoutingTests.swift @@ -0,0 +1,179 @@ +import AppKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateBareSpaceShortcutRoutingTests: XCTestCase { + private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var actionsWithPersistedShortcut: Set = [] + private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! + + override func setUp() { + super.setUp() + executionTimeAllowance = 30 + actionsWithPersistedShortcut = Set( + KeyboardShortcutSettings.Action.allCases.filter { + UserDefaults.standard.object(forKey: $0.defaultsKey) != nil + } + ) + savedShortcutsByAction = Dictionary( + uniqueKeysWithValues: actionsWithPersistedShortcut.map { action in + (action, KeyboardShortcutSettings.shortcut(for: action)) + } + ) + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.resetAll() + } + + override func tearDown() { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + for action in KeyboardShortcutSettings.Action.allCases { + if actionsWithPersistedShortcut.contains(action), + let savedShortcut = savedShortcutsByAction[action] { + KeyboardShortcutSettings.setShortcut(savedShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + super.tearDown() + } + + func testBareSpaceShortcutDispatchesConfiguredAction() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut(key: "space", command: false, shift: false, option: false, control: false) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let event = makeKeyDownEvent(key: " ", keyCode: 49, windowNumber: window.windowNumber) else { + XCTFail("Failed to construct Space event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Bare Space should dispatch when explicitly configured") + } + + func testBareSpaceChordPrefixArmsConfiguredShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: "space", + command: false, + shift: false, + option: false, + control: false, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent(key: " ", keyCode: 49, windowNumber: window.windowNumber), + let actionEvent = makeKeyDownEvent(key: "n", keyCode: 45, windowNumber: window.windowNumber) else { + XCTFail("Failed to construct Space chord events") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertEqual(manager.tabs.count, initialCount, "Bare Space prefix must not fire the action early") + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Bare Space chord should dispatch on the second stroke") + } + + private func makeKeyDownEvent( + key: String, + keyCode: UInt16, + windowNumber: Int + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + } + + private func withTemporaryShortcut( + action: KeyboardShortcutSettings.Action, + shortcut: StoredShortcut, + _ body: () -> Void + ) { + let hadPersistedShortcut = UserDefaults.standard.object(forKey: action.defaultsKey) != nil + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + if hadPersistedShortcut { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + KeyboardShortcutSettings.setShortcut(shortcut, for: action) + body() + } + + private func window(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(withId windowId: UUID) { + guard let window = window(withId: windowId) else { return } + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } +} diff --git a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift new file mode 100644 index 000000000000..31d6a9eb594e --- /dev/null +++ b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift @@ -0,0 +1,78 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class KeyboardShortcutSpaceKeyTests: XCTestCase { + func testShortcutConfigParsingRoundTripsSpaceKey() throws { + let spaceKeyCode = UInt16(0x31) + let shortcut = try XCTUnwrap(StoredShortcut.parseConfig("cmd+shift+space")) + + XCTAssertEqual(shortcut.key, "space") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + XCTAssertEqual( + shortcut.firstStroke.resolvedKeyCode { keyCode, _ in + keyCode == spaceKeyCode ? " " : nil + }, + spaceKeyCode + ) + XCTAssertEqual(shortcut.configIdentifier, "cmd+shift+space") + XCTAssertTrue( + shortcut.matches( + keyCode: spaceKeyCode, + modifierFlags: [.command, .shift], + eventCharacter: " " + ) + ) + + for rawShortcut in ["space", "cmd+space", "shift+space", "cmd+shift+space", "ctrl+space", "opt+space"] { + let parsedShortcut = try XCTUnwrap(StoredShortcut.parseConfig(rawShortcut)) + XCTAssertEqual(parsedShortcut.key, "space") + XCTAssertEqual(parsedShortcut.firstStroke.resolvedKeyCode(), spaceKeyCode) + XCTAssertEqual(parsedShortcut.configIdentifier, rawShortcut) + } + + XCTAssertEqual(StoredShortcut.parseConfig("cmd+shift+Space")?.configIdentifier, "cmd+shift+space") + XCTAssertEqual(StoredShortcut.parseConfig("cmd+shift+")?.configIdentifier, "cmd+shift+space") + XCTAssertEqual(StoredShortcut.parseConfig("cmd+shift+")?.configIdentifier, "cmd+shift+space") + XCTAssertEqual(StoredShortcut.parseConfig("cmd+shift+spacebar")?.configIdentifier, "cmd+shift+space") + XCTAssertEqual(StoredShortcut.parseConfig("cmd+shift+ ")?.configIdentifier, "cmd+shift+space") + XCTAssertEqual(StoredShortcut.parseConfig(" ")?.configIdentifier, "space") + } + + func testSettingsFileStoreParsesSpaceShortcutBinding() throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try """ + { + "shortcuts": { + "bindings": { + "toggleSplitZoom": "cmd+shift+space" + } + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .toggleSplitZoom), + StoredShortcut(key: "space", command: true, shift: true, option: false, control: false) + ) + } +} diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 18812e9d5184..5bc27d49845b 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -656,19 +656,55 @@ "shortcutBinding": { "oneOf": [ { - "type": "string", - "description": "Single-stroke shortcut, for example cmd+n. Use an empty string, none, clear, or unbound to unbind." + "$ref": "#/$defs/unboundShortcutBinding", + "description": "Unbind this shortcut. Accepted values are an empty string, none, clear, or unbound." + }, + { + "$ref": "#/$defs/shortcutFirstStroke", + "description": "Single-stroke shortcut, for example cmd+n or cmd+space." }, { "type": "array", "minItems": 1, "maxItems": 2, - "items": { - "type": "string" - }, + "prefixItems": [ + { + "$ref": "#/$defs/shortcutFirstStroke" + }, + { + "$ref": "#/$defs/shortcutStroke" + } + ], "description": "Chorded shortcut. Example: [\"ctrl+b\", \"c\"]." } ] + }, + "unboundShortcutBinding": { + "type": "string", + "enum": ["", "none", "clear", "unbound"] + }, + "shortcutFirstStroke": { + "allOf": [ + { + "$ref": "#/$defs/shortcutStroke" + }, + { + "anyOf": [ + { + "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)+" + }, + { + "pattern": "^(?: |[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>)$" + } + ] + } + ], + "description": "First or only shortcut stroke. Must include a modifier unless the key is Space." + }, + "shortcutStroke": { + "type": "string", + "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$", + "description": "One keyboard shortcut stroke using modifier+key syntax. Supported key names include space, Space, , and ." } } }