diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index fef42d494788..6d010a3e67d0 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -89,6 +89,8 @@ extension ShortcutAction { case .focusRight: return ShortcutStroke(key: "→", command: true, option: true) case .focusUp: return ShortcutStroke(key: "↑", command: true, option: true) case .focusDown: return ShortcutStroke(key: "↓", command: true, option: true) + case .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown: + return nil case .splitRight: return ShortcutStroke(key: "d", command: true) case .splitDown: return ShortcutStroke(key: "d", command: true, shift: true) case .toggleSplitZoom: return ShortcutStroke(key: "\r", command: true, shift: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index 41ce154511ea..9b2dca5ff507 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -92,6 +92,14 @@ extension ShortcutAction { case .focusRight: return "Focus Pane Right" case .focusUp: return "Focus Pane Up" case .focusDown: return "Focus Pane Down" + case .resizeSplitLeft: + return String(localized: "shortcut.resizeSplitLeft.label", defaultValue: "Resize Split Left") + case .resizeSplitRight: + return String(localized: "shortcut.resizeSplitRight.label", defaultValue: "Resize Split Right") + case .resizeSplitUp: + return String(localized: "shortcut.resizeSplitUp.label", defaultValue: "Resize Split Up") + case .resizeSplitDown: + return String(localized: "shortcut.resizeSplitDown.label", defaultValue: "Resize Split Down") case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .toggleSplitZoom: return "Toggle Pane Zoom" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index b3eb7a386d13..edc5e611decb 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -28,7 +28,9 @@ extension ShortcutAction { .cycleTextBoxSubmitAction, .attachTextBoxFile, .sendCtrlFToTerminal, .clearScreenKeepScrollback: return .navigation - case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, + case .focusLeft, .focusRight, .focusUp, .focusDown, + .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown, + .splitRight, .splitDown, .toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, .toggleRightSidebar, .fileExplorerOpenSelection, .fileExplorerOpenSelectionFinderAlias, .toggleCanvasLayout, .canvasRevealFocusedPane, .canvasOverview, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index e5b5cfb8ecf0..699b92329cf1 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -106,6 +106,10 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case focusRight case focusUp case focusDown + case resizeSplitLeft + case resizeSplitRight + case resizeSplitUp + case resizeSplitDown case splitRight case splitDown case toggleSplitZoom diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift index e5150c57cf4b..7782a9a6ae2a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift @@ -35,6 +35,26 @@ public struct ShortcutStroke: Sendable, Equatable, Hashable, Codable { /// True when at least one of `cmd`, `shift`, `opt`, or `ctrl` is set. public var hasAnyModifier: Bool { command || shift || option || control } + /// Canonical key token used when comparing stored shortcut strokes. + /// + /// AppKit reports arrow-key events as private-use function-key scalars, + /// while parsed config and built-in shortcuts use visible arrow glyphs. + /// Both representations identify the same physical key. + public var canonicalKeyToken: String { + Self.canonicalKeyToken(for: key) + } + + /// Returns the canonical token for a key string from either storage model. + public static func canonicalKeyToken(for key: String) -> String { + switch key { + case "\u{F702}": "←" + case "\u{F703}": "→" + case "\u{F700}": "↑" + case "\u{F701}": "↓" + default: key + } + } + /// Returns this stroke with its key normalized to cmux's persisted /// physical-key representation when a recording-time key code is present. public func canonicalized() -> ShortcutStroke { diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift index 6374bbe5bbcc..392cad8442db 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift @@ -75,8 +75,10 @@ struct ShortcutActionNumberedDigitTests { } } - @Test func fileExplorerOpenSelectionShortcutsAreSingleStrokeOnly() { + @Test func unsupportedChordActionsAreSingleStrokeOnly() { #expect(!ShortcutAction.fileExplorerOpenSelection.allowsChordShortcut) #expect(!ShortcutAction.fileExplorerOpenSelectionFinderAlias.allowsChordShortcut) + #expect(!ShortcutAction.cycleTextBoxSubmitAction.allowsChordShortcut) + #expect(!ShortcutAction.showHideAllWindows.allowsChordShortcut) } } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift new file mode 100644 index 000000000000..6db5e6892933 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("ShortcutAction") +struct ShortcutActionTests { + @Test func resizeSplitActionsExposePaneGroupNamesAndNoDefaults() { + let expected: [(ShortcutAction, String)] = [ + (.resizeSplitLeft, "Resize Split Left"), + (.resizeSplitRight, "Resize Split Right"), + (.resizeSplitUp, "Resize Split Up"), + (.resizeSplitDown, "Resize Split Down"), + ] + + for (action, displayName) in expected { + #expect(ShortcutAction.allCases.contains(action)) + #expect(action.group == .panes) + #expect(action.displayName == displayName) + #expect(action.defaultStroke == nil) + #expect(action.defaultShortcut == nil) + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift new file mode 100644 index 000000000000..f97fdd3e8bfb --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift @@ -0,0 +1,21 @@ +import CmuxSettings + +extension ShortcutListModel { + /// Whether the row recorder should collect two strokes for `action`. + /// + /// Existing chords stay in chord mode when Settings reopens. A user toggle + /// temporarily overrides that inferred state until the next assignment. + func chordsEnabled(for action: ShortcutAction) -> Bool { + guard action.allowsChordShortcut else { return false } + return chordModeOverrides[action.rawValue] ?? (effective(for: action)?.hasChord == true) + } + + /// Toggles whether the action's recorder collects a two-stroke chord. + func toggleChordMode(for action: ShortcutAction) { + guard action.allowsChordShortcut else { + setChordModeOverride(nil, for: action) + return + } + setChordModeOverride(!chordsEnabled(for: action), for: action) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index dc802333c782..31920ccd1fbe 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -15,7 +15,7 @@ final class ShortcutListModel { var legacyBindings: [String: StoredShortcut] private(set) var whenOverrideClauses: [String: ShortcutWhenClause] = [:] private(set) var whenOverrideRawStrings: [String: String] = [:] - private(set) var chordModeActions: Set = [] + private(set) var chordModeOverrides: [String: Bool] = [:] private(set) var restoreShortcuts: [String: StoredShortcut] = [:] private(set) var bareKeyRejections: Set = [] private(set) var primaryModifierRejections: Set = [] @@ -93,6 +93,10 @@ final class ShortcutListModel { var latestBindings: [String: StoredShortcut] { pendingBindings ?? bindings } + func setChordModeOverride(_ enabled: Bool?, for action: ShortcutAction) { + chordModeOverrides[action.rawValue] = enabled + } + private func ingestBindings(_ snapshot: ShortcutBindingsSnapshot) { let dictionary = snapshot.bindings let changedActionIds = Set(bindings.keys).union(dictionary.keys) @@ -320,10 +324,12 @@ final class ShortcutListModel { clearRejections(for: action) conflictRejections[action.rawValue] = conflict rejectedConflictShortcuts[action.rawValue] = proposed + chordModeOverrides.removeValue(forKey: action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed + chordModeOverrides.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) clearRejections(for: action) await write(updated, clearingLegacyFor: action) @@ -335,35 +341,35 @@ final class ShortcutListModel { func assignChord(_ chord: StoredShortcut, to action: ShortcutAction) async { let chord = chord.canonicalized() guard action.allowsChordShortcut else { - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } guard action.allowsBareFirstStroke || chord.first.hasAnyModifier else { markBareKeyRejected(action) - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } guard let proposed = normalizedNumberedShortcutIfNeeded(chord, for: action) else { clearRejections(for: action) numberedDigitRejections.insert(action.rawValue) - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } if action.rejectsSystemDefinedMediaKey(proposed) { markSystemReservedRejected(action) - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } if let conflict = detectConflict(for: action, stroke: proposed) { clearRejections(for: action) conflictRejections[action.rawValue] = conflict rejectedConflictShortcuts[action.rawValue] = proposed - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) clearRejections(for: action) await write(updated, clearingLegacyFor: action) @@ -373,6 +379,7 @@ final class ShortcutListModel { func clearBinding(for action: ShortcutAction) async { var updated = latestBindings updated[action.rawValue] = StoredShortcut.unbound + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated, clearingLegacyFor: action) } @@ -392,6 +399,7 @@ final class ShortcutListModel { /// Clears every override and all in-memory rejection/restore state — the /// "Reset Defaults" action. func resetAll() async { + chordModeOverrides.removeAll() restoreShortcuts.removeAll() bareKeyRejections.removeAll() primaryModifierRejections.removeAll() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift index e258ee9b051b..73fb6098ca95 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift @@ -5,6 +5,7 @@ struct ShortcutListRowActions { let onStroke: (ShortcutStroke) -> Void let onChord: (StoredShortcut) -> Void let onBareKeyRejected: () -> Void + let onToggleChordMode: () -> Void let onClearOrRestore: () -> Void let onClearRejections: () -> Void } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift index 4191a954333a..04cda80b5603 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift @@ -7,6 +7,7 @@ struct ShortcutListRowSnapshot: Equatable { let title: String let subtitle: String? let placeholder: String + let showsChordModeButton: Bool let chordsEnabled: Bool let hasPendingRejection: Bool let firstStrokeRequiresModifier: Bool diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift index 073c6f900c0a..afd6dc4cc41c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift @@ -52,6 +52,21 @@ struct ShortcutListRowView: View, Equatable { .frame(width: 160) .accessibilityIdentifier(snapshot.recorderAccessibilityIdentifier) + if snapshot.showsChordModeButton { + Button { + actions.onToggleChordMode() + } label: { + Image(systemName: "keyboard") + .imageScale(.medium) + .foregroundStyle(snapshot.chordsEnabled ? Color.accentColor : Color.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityLabel(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityAddTraits(snapshot.chordsEnabled ? .isSelected : []) + .accessibilityIdentifier("ShortcutRecorderChordModeButton.\(snapshot.action.rawValue)") + } + Button { actions.onClearOrRestore() } label: { diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift index 8169b0625d2c..f17ab382c09e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift @@ -22,7 +22,8 @@ struct ShortcutListStableLazyView: View { title: action.displayName, subtitle: model.scopeCaption(for: action), placeholder: model.formatPlaceholder(effective: effective, numbered: action.usesNumberedDigitMatching), - chordsEnabled: model.chordModeActions.contains(action.rawValue), + showsChordModeButton: action.allowsChordShortcut, + chordsEnabled: model.chordsEnabled(for: action), hasPendingRejection: model.hasPendingRejection(for: action), firstStrokeRequiresModifier: !action.allowsBareFirstStroke, isUnbound: effective?.isUnbound ?? true, @@ -36,6 +37,7 @@ struct ShortcutListStableLazyView: View { onStroke: { stroke in Task { await model.assign(stroke: stroke, to: action) } }, onChord: { chord in Task { await model.assignChord(chord, to: action) } }, onBareKeyRejected: { model.markBareKeyRejected(action) }, + onToggleChordMode: { model.toggleChordMode(for: action) }, onClearOrRestore: { Task { await model.clearOrRestore(for: action) } }, onClearRejections: { model.clearRejections(for: action) } ) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index c3bd5576fc5e..61c51a2e943e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -90,6 +90,7 @@ public struct GlobalHotkeySection: View { title: String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows"), subtitle: nil, placeholder: shortcutModel.formatPlaceholder(effective: effective, numbered: false), + showsChordModeButton: false, chordsEnabled: false, hasPendingRejection: shortcutModel.hasPendingRejection(for: hotkeyAction), firstStrokeRequiresModifier: true, @@ -102,6 +103,7 @@ public struct GlobalHotkeySection: View { onStroke: { stroke in Task { await shortcutModel.assign(stroke: stroke, to: hotkeyAction) } }, onChord: { _ in }, onBareKeyRejected: { shortcutModel.markBareKeyRejected(hotkeyAction) }, + onToggleChordMode: {}, onClearOrRestore: { Task { await shortcutModel.clearOrRestore(for: hotkeyAction) } }, onClearRejections: { shortcutModel.clearRejections(for: hotkeyAction) } ) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 7075ed95d32a..5f67d2a5c9e5 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -365,6 +365,153 @@ import CmuxSettings #expect(model.bareKeyRejections.contains(action.rawValue)) } + @Test func chordModeToggleEnablesRecorderForChordCapableAction() { + // WHY: the Settings row needs an explicit way to switch its recorder + // from one stroke to a two-stroke chord. The post-refactor model kept + // chord assignment but lost the user-triggered insertion path. + let (store, catalog, errorLog) = makeStore() + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + let chordAction = ShortcutAction.resizeSplitRight + let singleStrokeOnlyAction = ShortcutAction.fileExplorerOpenSelection + + #expect(!model.chordsEnabled(for: chordAction)) + model.toggleChordMode(for: chordAction) + #expect(model.chordsEnabled(for: chordAction)) + model.toggleChordMode(for: chordAction) + #expect(!model.chordsEnabled(for: chordAction)) + + model.toggleChordMode(for: singleStrokeOnlyAction) + #expect(!model.chordsEnabled(for: singleStrokeOnlyAction)) + } + + @Test func existingChordKeepsRecorderInChordMode() async throws { + // WHY: reopening Settings for an existing chord must keep the recorder + // in chord mode so replacing it records both strokes instead of silently + // downgrading the binding to a single stroke. + let (store, catalog, errorLog) = makeStore() + let action = ShortcutAction.resizeSplitRight + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + try await store.set([action.rawValue: chord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[action.rawValue] == chord }) + + #expect(model.chordsEnabled(for: action)) + } + + @Test func chordBindingsCanSharePrefixWhenSecondStrokesDiffer() async throws { + // WHY: tmux-style shortcut families intentionally share one prefix. + // Conflict detection must compare the complete sequence, not reject the + // second action merely because both chords begin with Ctrl+B. + let (store, catalog, errorLog) = makeStore() + let rightAction = ShortcutAction.resizeSplitRight + let leftAction = ShortcutAction.resizeSplitLeft + let rightChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + let leftChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "←", option: true) + ) + try await store.set([rightAction.rawValue: rightChord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[rightAction.rawValue] == rightChord }) + + await model.assignChord(leftChord, to: leftAction) + await spin(until: { model.bindings[leftAction.rawValue] == leftChord }) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[rightAction.rawValue] == rightChord) + #expect(storeBindings[leftAction.rawValue] == leftChord) + #expect(model.conflictRejections[leftAction.rawValue] == nil) + } + + @Test func identicalChordBindingIsRejected() async throws { + let (store, catalog, errorLog) = makeStore() + let conflictAction = ShortcutAction.resizeSplitRight + let targetAction = ShortcutAction.resizeSplitLeft + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + try await store.set([conflictAction.rawValue: chord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[conflictAction.rawValue] == chord }) + + await model.assignChord(chord, to: targetAction) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[targetAction.rawValue] == nil) + #expect(model.conflictRejections[targetAction.rawValue] == conflictAction) + } + + @Test func recordedPrivateUseArrowChordConflictsWithGlyphChord() async throws { + // NSEvent.charactersIgnoringModifiers reports arrow keys as AppKit + // private-use scalars, while cmux.json and defaults use arrow glyphs. + // The recorder must not persist a physically identical chord that the + // runtime will route to the already-bound action. + let (store, catalog, errorLog) = makeStore() + let conflictAction = ShortcutAction.resizeSplitRight + let targetAction = ShortcutAction.resizeSplitLeft + let configuredChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + let recordedChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true, keyCode: 11), + second: ShortcutStroke(key: "\u{F703}", option: true, keyCode: 124) + ) + try await store.set( + [conflictAction.rawValue: configuredChord], + for: catalog.shortcuts.bindings + ) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[conflictAction.rawValue] == configuredChord }) + + await model.assignChord(recordedChord, to: targetAction) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[targetAction.rawValue] == nil) + #expect(model.conflictRejections[targetAction.rawValue] == conflictAction) + } + + @Test(arguments: [ + ("\u{F702}", "←", UInt16(123)), + ("\u{F703}", "→", UInt16(124)), + ("\u{F700}", "↑", UInt16(126)), + ("\u{F701}", "↓", UInt16(125)), + ]) + func privateUseArrowKeysConflictWithCanonicalGlyphs( + privateUseKey: String, + canonicalKey: String, + keyCode: UInt16 + ) { + let recorded = ShortcutStroke( + key: privateUseKey, + option: true, + keyCode: keyCode + ) + let configured = ShortcutStroke(key: canonicalKey, option: true) + + #expect(numberedAwareStrokesConflict( + recorded, + numbered: false, + configured, + numbered: false + )) + } + @Test func assignChordWritesValidTwoStrokeChord() async throws { // WHY: assignChord is the recorder's onChord path for chord-capable // actions (wired from ShortcutListRowView). Only its rejection branches @@ -390,7 +537,7 @@ import CmuxSettings let storeBindings = await store.value(for: catalog.shortcuts.bindings) #expect(storeBindings[action.rawValue] == chord) // Happy path clears chord-mode arming and leaves no rejection state. - #expect(!model.chordModeActions.contains(action.rawValue)) + #expect(model.chordModeOverrides[action.rawValue] == nil) #expect(!model.numberedDigitRejections.contains(action.rawValue)) #expect(model.conflictRejections[action.rawValue] == nil) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 719cc4018166..c6d77ed83222 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -206994,6 +206994,10 @@ } } }, + "shortcut.resizeSplitDown.label": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تغيير حجم التقسيم للأسفل"}},"bs":{"stringUnit":{"state":"translated","value":"Promijeni veličinu podjele dolje"}},"da":{"stringUnit":{"state":"translated","value":"Tilpas opdeling nedad"}},"de":{"stringUnit":{"state":"translated","value":"Teilung nach unten anpassen"}},"en":{"stringUnit":{"state":"translated","value":"Resize Split Down"}},"es":{"stringUnit":{"state":"translated","value":"Redimensionar división hacia abajo"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner la division vers le bas"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona divisione in basso"}},"ja":{"stringUnit":{"state":"translated","value":"分割を下にリサイズ"}},"ko":{"stringUnit":{"state":"translated","value":"분할을 아래로 크기 조절"}},"km":{"stringUnit":{"state":"translated","value":"ប្ដូរទំហំការបំបែកចុះក្រោម"}},"nb":{"stringUnit":{"state":"translated","value":"Endre deling nedover"}},"pl":{"stringUnit":{"state":"translated","value":"Zmień rozmiar podziału w dół"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Divisão para Baixo"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер разделения вниз"}},"th":{"stringUnit":{"state":"translated","value":"ปรับขนาดการแบ่งลงด้านล่าง"}},"tr":{"stringUnit":{"state":"translated","value":"Bölmeyi Aşağı Yeniden Boyutlandır"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір розділення вниз"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"向下调整分割"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"向下調整分割"}}}}, + "shortcut.resizeSplitLeft.label": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تغيير حجم التقسيم لليسار"}},"bs":{"stringUnit":{"state":"translated","value":"Promijeni veličinu podjele lijevo"}},"da":{"stringUnit":{"state":"translated","value":"Tilpas opdeling mod venstre"}},"de":{"stringUnit":{"state":"translated","value":"Teilung nach links anpassen"}},"en":{"stringUnit":{"state":"translated","value":"Resize Split Left"}},"es":{"stringUnit":{"state":"translated","value":"Redimensionar división a la izquierda"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner la division vers la gauche"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona divisione a sinistra"}},"ja":{"stringUnit":{"state":"translated","value":"分割を左にリサイズ"}},"ko":{"stringUnit":{"state":"translated","value":"분할을 왼쪽으로 크기 조절"}},"km":{"stringUnit":{"state":"translated","value":"ប្ដូរទំហំការបំបែកទៅឆ្វេង"}},"nb":{"stringUnit":{"state":"translated","value":"Endre deling mot venstre"}},"pl":{"stringUnit":{"state":"translated","value":"Zmień rozmiar podziału w lewo"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Divisão à Esquerda"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер разделения влево"}},"th":{"stringUnit":{"state":"translated","value":"ปรับขนาดการแบ่งไปทางซ้าย"}},"tr":{"stringUnit":{"state":"translated","value":"Bölmeyi Sola Yeniden Boyutlandır"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір розділення ліворуч"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"向左调整分割"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"向左調整分割"}}}}, + "shortcut.resizeSplitRight.label": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تغيير حجم التقسيم لليمين"}},"bs":{"stringUnit":{"state":"translated","value":"Promijeni veličinu podjele desno"}},"da":{"stringUnit":{"state":"translated","value":"Tilpas opdeling mod højre"}},"de":{"stringUnit":{"state":"translated","value":"Teilung nach rechts anpassen"}},"en":{"stringUnit":{"state":"translated","value":"Resize Split Right"}},"es":{"stringUnit":{"state":"translated","value":"Redimensionar división a la derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner la division vers la droite"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona divisione a destra"}},"ja":{"stringUnit":{"state":"translated","value":"分割を右にリサイズ"}},"ko":{"stringUnit":{"state":"translated","value":"분할을 오른쪽으로 크기 조절"}},"km":{"stringUnit":{"state":"translated","value":"ប្ដូរទំហំការបំបែកទៅស្តាំ"}},"nb":{"stringUnit":{"state":"translated","value":"Endre deling mot høyre"}},"pl":{"stringUnit":{"state":"translated","value":"Zmień rozmiar podziału w prawo"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Divisão à Direita"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер разделения вправо"}},"th":{"stringUnit":{"state":"translated","value":"ปรับขนาดการแบ่งไปทางขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Bölmeyi Sağa Yeniden Boyutlandır"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір розділення праворуч"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"向右调整分割"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"向右調整分割"}}}}, + "shortcut.resizeSplitUp.label": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تغيير حجم التقسيم للأعلى"}},"bs":{"stringUnit":{"state":"translated","value":"Promijeni veličinu podjele gore"}},"da":{"stringUnit":{"state":"translated","value":"Tilpas opdeling opad"}},"de":{"stringUnit":{"state":"translated","value":"Teilung nach oben anpassen"}},"en":{"stringUnit":{"state":"translated","value":"Resize Split Up"}},"es":{"stringUnit":{"state":"translated","value":"Redimensionar división hacia arriba"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner la division vers le haut"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona divisione in alto"}},"ja":{"stringUnit":{"state":"translated","value":"分割を上にリサイズ"}},"ko":{"stringUnit":{"state":"translated","value":"분할을 위로 크기 조절"}},"km":{"stringUnit":{"state":"translated","value":"ប្ដូរទំហំការបំបែកឡើងលើ"}},"nb":{"stringUnit":{"state":"translated","value":"Endre deling oppover"}},"pl":{"stringUnit":{"state":"translated","value":"Zmień rozmiar podziału w górę"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Divisão para Cima"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер разделения вверх"}},"th":{"stringUnit":{"state":"translated","value":"ปรับขนาดการแบ่งขึ้นด้านบน"}},"tr":{"stringUnit":{"state":"translated","value":"Bölmeyi Yukarı Yeniden Boyutlandır"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір розділення вгору"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"向上调整分割"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"向上調整分割"}}}}, "shortcut.saveFilePreview.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index d8141f2c1e3b..e5cb0595424d 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -81,8 +81,11 @@ extension AppDelegate { /// focus. Callers invoke this from the command's existing dispatcher /// position so configured and compatibility shortcuts keep the same /// conflict precedence as the main area. - func performFocusedDockShortcut(_ command: DockShortcutCommand, event: NSEvent) -> Bool { - guard let store = focusedDockStoreForShortcut(preferredWindow: event.window) else { + func performFocusedDockShortcut( + _ command: DockShortcutCommand, + preferredWindow: NSWindow? + ) -> Bool { + guard let store = focusedDockStoreForShortcut(preferredWindow: preferredWindow) else { return false } if command.isFocusHistoryNavigation, !store.focusHistoryIncludesPanesAndTabs { @@ -92,6 +95,10 @@ extension AppDelegate { return true } + func performFocusedDockShortcut(_ command: DockShortcutCommand, event: NSEvent) -> Bool { + performFocusedDockShortcut(command, preferredWindow: event.window) + } + func matchesLegacyNextSurfaceShortcut(event: NSEvent) -> Bool { matchTabShortcut( event: event, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ef5ec5f125c0..8e31e3f96ebf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14047,13 +14047,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + let matchesResizeSplitLeft = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) + let matchesResizeSplitRight = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) + let matchesResizeSplitUp = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) + let matchesResizeSplitDown = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) + // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). + let shouldMatchGhosttyGotoSplitFallback = activeConfiguredShortcutChordPrefixForCurrentEvent == nil if matchConfiguredDirectionalShortcut( event: event, action: .focusLeft, arrowGlyph: "←", arrowKeyCode: 123 - ) || matchesGhosttyGotoSplitShortcut(event: event, direction: .left) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitLeft && + matchesGhosttyGotoSplitShortcut(event: event, direction: .left) + ) { if performFocusedDockShortcut(.focusPane(.left), event: event) { return true } let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) @@ -14068,7 +14098,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusRight, arrowGlyph: "→", arrowKeyCode: 124 - ) || matchesGhosttyGotoSplitShortcut(event: event, direction: .right) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitRight && + matchesGhosttyGotoSplitShortcut(event: event, direction: .right) + ) { if performFocusedDockShortcut(.focusPane(.right), event: event) { return true } let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) @@ -14083,7 +14117,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusUp, arrowGlyph: "↑", arrowKeyCode: 126 - ) || matchesGhosttyGotoSplitShortcut(event: event, direction: .up) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitUp && + matchesGhosttyGotoSplitShortcut(event: event, direction: .up) + ) { if performFocusedDockShortcut(.focusPane(.up), event: event) { return true } let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) @@ -14098,7 +14136,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusDown, arrowGlyph: "↓", arrowKeyCode: 125 - ) || matchesGhosttyGotoSplitShortcut(event: event, direction: .down) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitDown && + matchesGhosttyGotoSplitShortcut(event: event, direction: .down) + ) { if performFocusedDockShortcut(.focusPane(.down), event: event) { return true } let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) @@ -14109,6 +14151,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchesResizeSplitLeft { + _ = performResizeSplitShortcut( + direction: .left, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchesResizeSplitRight { + _ = performResizeSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchesResizeSplitUp { + _ = performResizeSplitShortcut( + direction: .up, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchesResizeSplitDown { + _ = performResizeSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + if matchesGhosttyGotoSplitPreviousShortcut(event) { cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.cyclePaneFocus(forward: false) #if DEBUG @@ -15003,6 +15077,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + @discardableResult + func performResizeSplitShortcut( + direction: ResizeDirection, + preferredWindow: NSWindow? = nil + ) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if performFocusedDockShortcut( + .resizeSplit(direction, amount: splitResizeShortcutStepPixels), + preferredWindow: targetWindow + ) { + return true + } + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + let routedManager = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) + + if let terminalContext { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(tabManager: terminalContext.tabManager) { + return true + } + return terminalContext.tabManager.resizeSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction, + amount: splitResizeShortcutStepPixels + ) + } + + if shouldSuppressSplitShortcutForTransientTerminalFocusState(tabManager: routedManager) { + return true + } + return routedManager?.resizeFocusedSplit( + direction: direction, + amount: splitResizeShortcutStepPixels + ) ?? false + } + @discardableResult func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow @@ -15375,11 +15485,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func configuredShortcutStrokesMatch(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { + CmuxSettings.ShortcutStroke.canonicalKeyToken(for: lhs.key) + == CmuxSettings.ShortcutStroke.canonicalKeyToken(for: rhs.key) + && lhs.command == rhs.command + && lhs.shift == rhs.shift + && lhs.option == rhs.option + && lhs.control == rhs.control + } + private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return false } return matchShortcutStroke(event: event, stroke: secondStroke) @@ -15438,7 +15557,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !shortcut.isUnbound else { return nil } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return nil } return numberedShortcutDigit(event: event, stroke: secondStroke) @@ -15472,7 +15591,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return false } return matchDirectionalShortcut( diff --git a/Sources/DockSplitStore+ShortcutCommands.swift b/Sources/DockSplitStore+ShortcutCommands.swift index 914648cc67ef..e96adac2d936 100644 --- a/Sources/DockSplitStore+ShortcutCommands.swift +++ b/Sources/DockSplitStore+ShortcutCommands.swift @@ -1,4 +1,5 @@ import Bonsplit +import CmuxPanes import CmuxWorkspaces import Foundation @@ -8,6 +9,7 @@ enum DockShortcutCommand { case selectSurface(number: Int) case moveSurface(offset: Int) case focusPane(NavigationDirection) + case resizeSplit(ResizeDirection, amount: UInt16) case togglePaneZoom case focusHistoryBack case focusHistoryForward @@ -46,6 +48,21 @@ extension DockSplitStore { bonsplitController.navigateFocus(direction: direction) applyFocusedShortcutSelection() return true + case .resizeSplit(let direction, let amount): + guard amount > 0, + let focusedPanelId, + let paneId = paneId(forPanelId: focusedPanelId) else { return false } + let didResize = PaneLayoutService().resizeSplit( + in: bonsplitController.treeSnapshot(), + targetPaneId: paneId.id.uuidString, + direction: direction, + amountPixels: amount, + controller: bonsplitController + ) + if didResize { + scheduleDockPortalReconcile(reason: "dock.shortcut.resize") + } + return didResize case .togglePaneZoom: guard let pane = bonsplitController.focusedPaneId else { return false } return toggleDockPaneZoom(inPane: pane) diff --git a/Sources/KeyboardShortcutSettings+ActionMetadata.swift b/Sources/KeyboardShortcutSettings+ActionMetadata.swift index df5aa3ed2e33..294780f3a02b 100644 --- a/Sources/KeyboardShortcutSettings+ActionMetadata.swift +++ b/Sources/KeyboardShortcutSettings+ActionMetadata.swift @@ -2,7 +2,8 @@ extension KeyboardShortcutSettings.Action { var isSystemWideHotkey: Bool { self == .showHideAllWindows } var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection + self != .showHideAllWindows + && self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 9d6d22997005..7967e2611d48 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -135,6 +135,10 @@ enum KeyboardShortcutSettings { case focusRight case focusUp case focusDown + case resizeSplitLeft + case resizeSplitRight + case resizeSplitUp + case resizeSplitDown case splitRight case splitDown, toggleSplitZoom case equalizeSplits @@ -278,6 +282,10 @@ enum KeyboardShortcutSettings { case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right") case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up") case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down") + case .resizeSplitLeft: return String(localized: "shortcut.resizeSplitLeft.label", defaultValue: "Resize Split Left") + case .resizeSplitRight: return String(localized: "shortcut.resizeSplitRight.label", defaultValue: "Resize Split Right") + case .resizeSplitUp: return String(localized: "shortcut.resizeSplitUp.label", defaultValue: "Resize Split Up") + case .resizeSplitDown: return String(localized: "shortcut.resizeSplitDown.label", defaultValue: "Resize Split Down") case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right") case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down") case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") @@ -467,6 +475,11 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "↑", command: true, shift: false, option: true, control: false) case .focusDown: return StoredShortcut(key: "↓", command: true, shift: false, option: true, control: false) + case .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown: + // Unbound by default: a tmux-style Ctrl+B prefix would consume + // Ctrl+B before the focused terminal, shell, or nested tmux sees it. + // Users can still opt into chorded resize bindings via Settings or cmux.json. + return .unbound case .splitRight: return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7856ff3be48c..818e56b7af4b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -26,6 +26,9 @@ typealias Tab = Workspace private let tabManagerLogger = Logger(subsystem: "com.cmuxterm.app", category: "TabManager") +/// Default keyboard-driven split resize distance in pixels. +let splitResizeShortcutStepPixels: UInt16 = 120 + enum WorkspaceOrderChangeNotificationKey { static let movedWorkspaceIds = "movedWorkspaceIds" } @@ -3881,13 +3884,28 @@ class TabManager: ObservableObject { return false } - return paneLayout.resizeSplit( + let didResize = paneLayout.resizeSplit( in: tab.bonsplitController.treeSnapshot(), targetPaneId: paneUUID.uuidString, direction: direction, amountPixels: amount, controller: tab.bonsplitController ) + if didResize { + tab.didProgrammaticallyChangeSplitGeometry() + } + return didResize + } + + /// Resize the currently focused split edge in the selected workspace. + @discardableResult + func resizeFocusedSplit( + direction: ResizeDirection, + amount: UInt16 = splitResizeShortcutStepPixels + ) -> Bool { + guard let tab = selectedWorkspace, + let focusedPanelId = tab.focusedPanelId else { return false } + return resizeSplit(tabId: tab.id, surfaceId: focusedPanelId, direction: direction, amount: amount) } /// Toggle zoom on a panel. diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 20fe0493795b..2dcbbdd3ffa9 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -158,6 +158,425 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { shortcutRoutingAssertPaneFramesMatch(cachedEqualizedLayout, liveEqualizedLayout) } + func testTmuxStyleResizeSplitShortcutMovesFocusedDividerAndUpdatesCachedLayout() { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: tmuxStyleResizeShortcut(chordKey: "→")) { + 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), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node") + return + } + + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + workspace.splitTabBar( + workspace.bonsplitController, + didChangeGeometry: workspace.bonsplitController.layoutSnapshot() + ) + guard let seededLayoutSnapshot = workspace.tmuxLayoutSnapshot else { + XCTFail("Expected cached layout snapshot after seeding split geometry") + return + } + +#if DEBUG + let originalGhosttyGotoSplitRightShortcut = appDelegate.ghosttyGotoSplitRightShortcut + appDelegate.ghosttyGotoSplitRightShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false + ) + defer { + appDelegate.ghosttyGotoSplitRightShortcut = originalGhosttyGotoSplitRightShortcut + } +#endif + + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: "→", + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct tmux resize shortcut events") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + guard let armedSplit = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected split node after arming chord") + return + } + XCTAssertEqual( + armedSplit.dividerPosition, + 0.5, + accuracy: 0.000_1 + ) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + + let liveResizedLayout = workspace.bonsplitController.layoutSnapshot() + guard let cachedResizedLayout = workspace.tmuxLayoutSnapshot else { + XCTFail("Expected cached layout snapshot after resizing split geometry") + return + } + XCTAssertNotEqual( + shortcutRoutingPaneFramesById(in: seededLayoutSnapshot), + shortcutRoutingPaneFramesById(in: liveResizedLayout) + ) + shortcutRoutingAssertPaneFramesMatch(cachedResizedLayout, liveResizedLayout) + } + } + + func testSingleStrokeResizeShortcutPrecedesGhosttyGotoSplitFallback() { + let resizeShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false + ) + withTemporaryShortcut(action: .resizeSplitRight, shortcut: resizeShortcut) { + 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), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id), + let event = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and Option+Right event") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + let originalGhosttyGotoSplitRightShortcut = appDelegate.ghosttyGotoSplitRightShortcut + appDelegate.ghosttyGotoSplitRightShortcut = resizeShortcut + defer { + appDelegate.ghosttyGotoSplitRightShortcut = originalGhosttyGotoSplitRightShortcut + } + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + + func testExplicitFocusShortcutPrecedesConfiguredResizeShortcut() { + let sharedShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false + ) + withTemporaryShortcut(action: .focusRight, shortcut: sharedShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: sharedShortcut) { + 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), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit( + from: leftPanelId, + orientation: .horizontal + ) else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let event = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and Option+Right event") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let routedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected routed split node") + return + } + XCTAssertEqual(routedSplit.dividerPosition, 0.5, accuracy: 0.000_1) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + } + } + } + + func testSharedChordPrefixIgnoresRecordedKeyCodeMetadataAtRuntime() { + let recordedFocusShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + keyCode: 11, + chordKey: "←", + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + let configuredResizeShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "→", + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + withTemporaryShortcut(action: .focusRight, shortcut: recordedFocusShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: configuredResizeShortcut) { + 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), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and mixed-metadata chord events") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + } + + func testSharedArrowChordPrefixCanonicalizesRecordedAndConfiguredRepresentations() { + let recordedFocusShortcut = StoredShortcut( + key: "\u{F703}", + command: false, + shift: false, + option: false, + control: true, + keyCode: 124, + chordKey: "x", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ) + let configuredResizeShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: false, + control: true, + chordKey: "r", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ) + withTemporaryShortcut(action: .focusRight, shortcut: recordedFocusShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: configuredResizeShortcut) { + 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), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let prefixEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.control], + keyCode: 124, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: "r", + modifiers: [.control], + keyCode: 15, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and mixed-representation chord events") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + } + private func shortcutRoutingSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { switch node { case .pane: @@ -276,6 +695,21 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { body() } + private func tmuxStyleResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: chordKey, + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + } + private func window(withId windowId: UUID) -> NSWindow? { let identifier = "cmux.main.\(windowId.uuidString)" return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) diff --git a/cmuxTests/DockShortcutRoutingTests.swift b/cmuxTests/DockShortcutRoutingTests.swift index b12ba65fb296..9cd2a3214380 100644 --- a/cmuxTests/DockShortcutRoutingTests.swift +++ b/cmuxTests/DockShortcutRoutingTests.swift @@ -83,6 +83,129 @@ struct DockShortcutRoutingTests { } } + @Test("Customized resize shortcut moves only the focused Dock split") + @MainActor + func customizedResizeTargetsFocusedDock() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let leftPanel = try #require( + harness.dock.newSurface(kind: .terminal, inPane: harness.rootPane, focus: true) + ) + _ = try #require( + harness.dock.newSplit( + kind: .terminal, + orientation: .horizontal, + insertFirst: false, + sourcePanelId: leftPanel, + focus: true + ) + ) + let leftPane = try #require(harness.dock.paneId(forPanelId: leftPanel)) + harness.dock.focusPanel(leftPanel) + + let dockSplit = try #require(Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot())) + let dockSplitId = try #require(UUID(uuidString: dockSplit.id)) + #expect(harness.dock.bonsplitController.setDividerPosition(0.5, forSplit: dockSplitId)) + + let mainPanel = try #require(harness.mainWorkspace.focusedPanelId) + _ = try #require( + harness.mainWorkspace.newTerminalSplit( + from: mainPanel, + orientation: .horizontal + ) + ) + let mainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + let mainSplitId = try #require(UUID(uuidString: mainSplit.id)) + #expect( + harness.mainWorkspace.bonsplitController.setDividerPosition( + 0.35, + forSplit: mainSplitId + ) + ) + + let resize = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut(resize, for: .resizeSplitRight) + harness.dock.dockPortalReconcileState.scheduledRequestCount = 0 + + #expect(Self.dispatch(resize, in: harness)) + #expect(harness.dock.dockPortalReconcileState.scheduledRequestCount > 0) + + let resizedDockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let unchangedMainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + #expect(resizedDockSplit.dividerPosition > 0.5) + #expect(abs(unchangedMainSplit.dividerPosition - 0.35) < 0.0001) + #expect(harness.dock.focusedPanelId == leftPanel) + #expect(harness.dock.bonsplitController.focusedPaneId == leftPane) + } + } + } + + @Test("Resize at a missing Dock border is consumed without falling through") + @MainActor + func resizeAtMissingDockBorderDoesNotResizeMainSplit() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let leftDockPanel = try #require( + harness.dock.newSurface(kind: .terminal, inPane: harness.rootPane, focus: true) + ) + _ = try #require( + harness.dock.newSplit( + kind: .terminal, + orientation: .horizontal, + insertFirst: false, + sourcePanelId: leftDockPanel, + focus: true + ) + ) + harness.dock.focusPanel(leftDockPanel) + let dockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let dockSplitId = try #require(UUID(uuidString: dockSplit.id)) + #expect(harness.dock.bonsplitController.setDividerPosition(0.4, forSplit: dockSplitId)) + + let leftMainPanel = try #require(harness.mainWorkspace.focusedPanelId) + let rightMainPanel = try #require( + harness.mainWorkspace.newTerminalSplit( + from: leftMainPanel, + orientation: .horizontal + ) + ) + #expect(harness.mainWorkspace.focusedPanelId == rightMainPanel.id) + let mainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + let mainSplitId = try #require(UUID(uuidString: mainSplit.id)) + #expect( + harness.mainWorkspace.bonsplitController.setDividerPosition( + 0.6, + forSplit: mainSplitId + ) + ) + + let resizeLeft = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut(resizeLeft, for: .resizeSplitLeft) + + #expect(Self.dispatch(resizeLeft, in: harness)) + + let unchangedDockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let unchangedMainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + #expect(abs(unchangedDockSplit.dividerPosition - 0.4) < 0.0001) + #expect(abs(unchangedMainSplit.dividerPosition - 0.6) < 0.0001) + } + } + } + @Test("Legacy tab shortcuts target the focused Dock") @MainActor func legacyTabShortcutsTargetFocusedDock() async throws { @@ -627,6 +750,15 @@ private extension DockShortcutRoutingTests { control: true ) } + + static func firstSplit(in node: ExternalTreeNode) -> ExternalSplitNode? { + switch node { + case .pane: + return nil + case .split(let split): + return split + } + } } @MainActor diff --git a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift index dc4937772fff..7cecfc08cb2b 100644 --- a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift @@ -1,4 +1,5 @@ import XCTest +import enum CmuxSettings.ShortcutAction #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,6 +8,43 @@ import XCTest #endif final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { + func testSettingsPackageCatalogIncludesResizeSplitShortcutActions() { + let expected: [(KeyboardShortcutSettings.Action, ShortcutAction)] = [ + (.resizeSplitLeft, .resizeSplitLeft), + (.resizeSplitRight, .resizeSplitRight), + (.resizeSplitUp, .resizeSplitUp), + (.resizeSplitDown, .resizeSplitDown), + ] + + let packageActions = Set(ShortcutAction.allCases) + for (appAction, packageAction) in expected { + XCTAssertTrue(packageActions.contains(packageAction)) + XCTAssertEqual(packageAction.rawValue, appAction.rawValue) + } + } + + func testResizeSplitDefaultsAreUnbound() { + let expected: [KeyboardShortcutSettings.Action] = [ + .resizeSplitLeft, + .resizeSplitRight, + .resizeSplitUp, + .resizeSplitDown, + ] + + for action in expected { + XCTAssertEqual( + action.defaultShortcut, + .unbound, + "Expected \(action.rawValue) to be opt-in so Ctrl+B reaches the focused terminal" + ) + } + } + + func testSystemWideShortcutActionDoesNotAllowChords() { + XCTAssertFalse(KeyboardShortcutSettings.Action.showHideAllWindows.allowsChordShortcut) + XCTAssertFalse(ShortcutAction.showHideAllWindows.allowsChordShortcut) + } + func testSettingsFileStoreParsesEqualizeSplitsShortcut() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -35,6 +73,36 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { ) } + func testSettingsFileStoreParsesResizeSplitShortcutChord() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "bindings": { + "resizeSplitRight": ["ctrl+b", "alt+right"] + } + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .resizeSplitRight), + tmuxResizeShortcut(chordKey: "→") + ) + } + func testSettingsFileStoreParsesSystemWideHotkeyWithoutSharedStoreRecursion() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -73,4 +141,19 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { private func writeSettingsFile(_ contents: String, to url: URL) throws { try contents.data(using: .utf8)?.write(to: url) } + + private func tmuxResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: chordKey, + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + } } diff --git a/web/app/[locale]/(landing)/docs/configuration/page.tsx b/web/app/[locale]/(landing)/docs/configuration/page.tsx index 9107d4291c7a..c6b4ff924a11 100644 --- a/web/app/[locale]/(landing)/docs/configuration/page.tsx +++ b/web/app/[locale]/(landing)/docs/configuration/page.tsx @@ -5,7 +5,11 @@ import { DocsLink as Link } from "@/app/[locale]/components/docs-link"; import { CodeBlock } from "@/app/[locale]/components/code-block"; import { Callout } from "@/app/[locale]/components/callout"; import settingsSchema from "@/data/cmux.schema.json"; -import { localizedShortcutText, shortcutCategories } from "@/data/cmux-shortcuts"; +import { + localizedShortcutText, + shortcutCategories, + type ShortcutSequence, +} from "@/data/cmux-shortcuts"; import { DocsHeading } from "@/app/[locale]/components/docs-heading"; type SchemaProperty = { @@ -142,6 +146,7 @@ function buildSettingsFileExample(t: ConfigurationTranslation) { // "toggleSidebar": "cmd+b", // "toggleFileExplorer": "cmd+opt+b", // "newTab": ["ctrl+b", "c"], + // "resizeSplitRight": ["ctrl+b", "alt+right"], // "commandPalettePrevious": null // } // }, @@ -157,9 +162,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s }); } -function shortcutToConfig(shortcut: { combos: string[][]; configValue?: string }) { +function shortcutToConfig(shortcut: { + combos: string[][]; + chordCombos?: ShortcutSequence[]; + configValue?: string; +}) { if (shortcut.configValue) return shortcut.configValue; - return shortcutComboToConfig(shortcut.combos[0] ?? []); + return shortcutSequenceToConfig(shortcut.chordCombos?.[0] ?? [shortcut.combos[0] ?? []]); } function shortcutComboToConfig(combo: string[]) { @@ -183,6 +192,14 @@ function shortcutComboToConfig(combo: string[]) { .join("+"); } +function shortcutSequenceToConfig(sequence: ShortcutSequence) { + const strokes = sequence.map(shortcutComboToConfig).filter(Boolean); + if (strokes.length <= 1) { + return strokes[0] ?? ""; + } + return JSON.stringify(strokes); +} + function formatSchemaType(property: SchemaProperty): string { if (property.oneOf?.length) { return property.oneOf.map(formatSchemaType).join(" | "); diff --git a/web/app/[locale]/(landing)/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/(landing)/docs/keyboard-shortcuts/page.tsx index 3c310f81b98d..c82ea3dc0736 100644 --- a/web/app/[locale]/(landing)/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/(landing)/docs/keyboard-shortcuts/page.tsx @@ -12,6 +12,7 @@ const shortcutChordExample = `{ "bindings": { "newSurface": ["ctrl+b", "c"], "showNotifications": ["ctrl+b", "i"], + "resizeSplitRight": ["ctrl+b", "alt+right"], "toggleSidebar": "cmd+b", "toggleFileExplorer": "cmd+opt+b", "splitRight": "", diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index 61f33ba99638..b24343522543 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -2,7 +2,13 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { localizedShortcutText, shortcutCategories, type Shortcut } from "../../data/cmux-shortcuts"; +import { + localizedShortcutText, + shortcutCategories, + shortcutSequences, + type Shortcut, + type ShortcutSequence, +} from "../../data/cmux-shortcuts"; function normalize(s: string) { return s.toLowerCase().replace(/\s+/g, " ").trim(); @@ -12,6 +18,10 @@ function comboToText(combo: string[]) { return combo.join(" "); } +function sequenceToText(sequence: ShortcutSequence, separator: string) { + return sequence.map(comboToText).join(` ${separator} `); +} + function KeyCombo({ combo }: { combo: string[] }) { return ( @@ -29,9 +39,35 @@ function KeyCombo({ combo }: { combo: string[] }) { ); } -function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string }) { +function KeySequence({ sequence, separator }: { sequence: ShortcutSequence; separator: string }) { + return ( + + {sequence.map((combo, idx) => ( + + {idx > 0 && ( + + {separator} + + )} + + + ))} + + ); +} + +function ShortcutRow({ + shortcut, + locale, + sequenceSeparator, +}: { + shortcut: Shortcut; + locale: string; + sequenceSeparator: string; +}) { const description = localizedShortcutText(shortcut.description, locale); const note = shortcut.note ? localizedShortcutText(shortcut.note, locale) : undefined; + const sequences = shortcutSequences(shortcut); return (
@@ -40,14 +76,14 @@ function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string {note && {note}}
- {shortcut.combos.map((combo, idx) => ( - + {sequences.map((sequence, idx) => ( + {idx > 0 && ( / )} - + ))}
@@ -59,6 +95,7 @@ export function KeyboardShortcuts() { const [query, setQuery] = useState(""); const locale = useLocale(); const t = useTranslations("docs.keyboardShortcuts"); + const sequenceSeparator = t("sequenceSeparator"); const trimmedQuery = query.trim(); @@ -71,11 +108,13 @@ export function KeyboardShortcuts() { const catTitle = t(`cat.${cat.titleKey}`); const description = localizedShortcutText(shortcut.description, locale); const note = shortcut.note ? localizedShortcutText(shortcut.note, locale) : ""; - const combos = shortcut.combos.map(comboToText).join(" "); + const combos = shortcutSequences(shortcut) + .map((sequence) => sequenceToText(sequence, sequenceSeparator)) + .join(" "); return normalize(`${catTitle} ${combos} ${description} ${note}`).includes(q); }), })).filter((cat) => cat.shortcuts.length > 0); - }, [locale, query, t]); + }, [locale, query, sequenceSeparator, t]); return (
@@ -144,7 +183,12 @@ export function KeyboardShortcuts() {
{cat.shortcuts.map((shortcut) => ( - + ))}
diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index ed0a0468523a..553f4db3a1f5 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -5,13 +5,132 @@ export type LocalizedText = { ja: string; } & Partial, string>>; +type ResizeSplitDirection = "left" | "right" | "up" | "down"; + +const resizeSplitDescriptions = { + left: { + en: "Resize split left", + ja: "分割を左にリサイズ", + "zh-CN": "向左调整分割大小", + "zh-TW": "向左調整分割大小", + ko: "분할 크기를 왼쪽으로 조정", + de: "Teilung nach links anpassen", + es: "Redimensionar la división hacia la izquierda", + fr: "Redimensionner la division vers la gauche", + it: "Ridimensiona la divisione verso sinistra", + da: "Tilpas opdelingen mod venstre", + pl: "Zmień rozmiar podziału w lewo", + ru: "Изменить размер разделения влево", + bs: "Promijeni veličinu podjele ulijevo", + ar: "تغيير حجم الجزء نحو اليسار", + no: "Endre størrelsen på delingen mot venstre", + "pt-BR": "Redimensionar a divisão para a esquerda", + th: "ปรับขนาดช่องแบ่งไปทางซ้าย", + tr: "Bölmeyi sola doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកទៅឆ្វេង", + uk: "Змінити розмір поділу вліво", + }, + right: { + en: "Resize split right", + ja: "分割を右にリサイズ", + "zh-CN": "向右调整分割大小", + "zh-TW": "向右調整分割大小", + ko: "분할 크기를 오른쪽으로 조정", + de: "Teilung nach rechts anpassen", + es: "Redimensionar la división hacia la derecha", + fr: "Redimensionner la division vers la droite", + it: "Ridimensiona la divisione verso destra", + da: "Tilpas opdelingen mod højre", + pl: "Zmień rozmiar podziału w prawo", + ru: "Изменить размер разделения вправо", + bs: "Promijeni veličinu podjele udesno", + ar: "تغيير حجم الجزء نحو اليمين", + no: "Endre størrelsen på delingen mot høyre", + "pt-BR": "Redimensionar a divisão para a direita", + th: "ปรับขนาดช่องแบ่งไปทางขวา", + tr: "Bölmeyi sağa doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកទៅស្ដាំ", + uk: "Змінити розмір поділу вправо", + }, + up: { + en: "Resize split up", + ja: "分割を上にリサイズ", + "zh-CN": "向上调整分割大小", + "zh-TW": "向上調整分割大小", + ko: "분할 크기를 위쪽으로 조정", + de: "Teilung nach oben anpassen", + es: "Redimensionar la división hacia arriba", + fr: "Redimensionner la division vers le haut", + it: "Ridimensiona la divisione verso l'alto", + da: "Tilpas opdelingen opad", + pl: "Zmień rozmiar podziału w górę", + ru: "Изменить размер разделения вверх", + bs: "Promijeni veličinu podjele prema gore", + ar: "تغيير حجم الجزء نحو الأعلى", + no: "Endre størrelsen på delingen oppover", + "pt-BR": "Redimensionar a divisão para cima", + th: "ปรับขนาดช่องแบ่งขึ้น", + tr: "Bölmeyi yukarı doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកឡើងលើ", + uk: "Змінити розмір поділу вгору", + }, + down: { + en: "Resize split down", + ja: "分割を下にリサイズ", + "zh-CN": "向下调整分割大小", + "zh-TW": "向下調整分割大小", + ko: "분할 크기를 아래쪽으로 조정", + de: "Teilung nach unten anpassen", + es: "Redimensionar la división hacia abajo", + fr: "Redimensionner la division vers le bas", + it: "Ridimensiona la divisione verso il basso", + da: "Tilpas opdelingen nedad", + pl: "Zmień rozmiar podziału w dół", + ru: "Изменить размер разделения вниз", + bs: "Promijeni veličinu podjele prema dolje", + ar: "تغيير حجم الجزء نحو الأسفل", + no: "Endre størrelsen på delingen nedover", + "pt-BR": "Redimensionar a divisão para baixo", + th: "ปรับขนาดช่องแบ่งลง", + tr: "Bölmeyi aşağı doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកចុះក្រោម", + uk: "Змінити розмір поділу вниз", + }, +} satisfies Record>; + +const resizeSplitUnboundNote = { + en: "unbound by default", + ja: "デフォルトでは未割り当て", + "zh-CN": "默认未绑定", + "zh-TW": "預設未綁定", + ko: "기본적으로 할당되지 않음", + de: "standardmäßig nicht belegt", + es: "sin asignar de forma predeterminada", + fr: "non attribué par défaut", + it: "non assegnato per impostazione predefinita", + da: "ikke tildelt som standard", + pl: "domyślnie nieprzypisany", + ru: "по умолчанию не назначено", + bs: "podrazumijevano nije dodijeljeno", + ar: "غير معيّن افتراضيًا", + no: "ikke tilordnet som standard", + "pt-BR": "não atribuído por padrão", + th: "ไม่ได้กำหนดไว้โดยค่าเริ่มต้น", + tr: "varsayılan olarak atanmamış", + km: "មិនបានកំណត់តាមលំនាំដើម", + uk: "типово не призначено", +} satisfies Record; + export function localizedShortcutText(text: LocalizedText, locale: string) { return text[locale as keyof LocalizedText] ?? (locale.startsWith("ja") ? text.ja : text.en); } +export type ShortcutSequence = string[][]; + export type Shortcut = { id: string; combos: string[][]; + chordCombos?: ShortcutSequence[]; description: LocalizedText; note?: LocalizedText; configValue?: string; @@ -24,6 +143,10 @@ export type ShortcutCategory = { shortcuts: Shortcut[]; }; +export function shortcutSequences(shortcut: Shortcut): ShortcutSequence[] { + return shortcut.chordCombos ?? shortcut.combos.map((combo) => [combo]); +} + export const shortcutCategories: ShortcutCategory[] = [ { id: "app", @@ -313,6 +436,30 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, + { + id: "resizeSplitLeft", + combos: [], + description: resizeSplitDescriptions.left, + note: resizeSplitUnboundNote, + }, + { + id: "resizeSplitRight", + combos: [], + description: resizeSplitDescriptions.right, + note: resizeSplitUnboundNote, + }, + { + id: "resizeSplitUp", + combos: [], + description: resizeSplitDescriptions.up, + note: resizeSplitUnboundNote, + }, + { + id: "resizeSplitDown", + combos: [], + description: resizeSplitDescriptions.down, + note: resizeSplitUnboundNote, + }, { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, @@ -496,7 +643,8 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "diffViewerScrollToTop", - combos: [["G", "G"]], + combos: [], + chordCombos: [[["G"], ["G"]]], description: { en: "Scroll diff to top", ja: "差分の先頭へスクロール" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["g", "g"]', @@ -509,14 +657,16 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "diffViewerNextFile", - combos: [["]", "F"]], + combos: [], + chordCombos: [[["]"], ["F"]]], description: { en: "Jump to next diff file", ja: "次の差分ファイルへ移動" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["]", "f"]', }, { id: "diffViewerPreviousFile", - combos: [["[", "F"]], + combos: [], + chordCombos: [[["["], ["F"]]], description: { en: "Jump to previous diff file", ja: "前の差分ファイルへ移動" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["[", "f"]]', diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b507d47ce05b..6b191957fd63 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1659,6 +1659,10 @@ "focusRight", "focusUp", "focusDown", + "resizeSplitLeft", + "resizeSplitRight", + "resizeSplitUp", + "resizeSplitDown", "splitRight", "splitDown", "toggleSplitZoom", diff --git a/web/messages/ar.json b/web/messages/ar.json index d9ecbdba8934..daaf957ef43c 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "اختصارات لوحة المفاتيح", "description": "جميع اختصارات لوحة المفاتيح المتاحة في cmux، مجمعة حسب الفئة.", + "sequenceSeparator": "ثم", "metaDescription": "جميع اختصارات لوحة مفاتيح cmux لمساحات العمل والأسطح والألواح المقسمة والمتصفح والإشعارات والبحث وإدارة النوافذ على macOS.", "searchPlaceholder": "بحث في الاختصارات...", "searchLabel": "بحث اختصارات لوحة المفاتيح", diff --git a/web/messages/bs.json b/web/messages/bs.json index cabfd9fe646d..393c5aa7f8b1 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Prečice na tastaturi", "description": "Sve prečice na tastaturi dostupne u cmux-u, grupirane po kategorijama.", + "sequenceSeparator": "zatim", "metaDescription": "Sve cmux prečice na tastaturi za radne prostore, površine, podijeljene panele, preglednik, notifikacije, pretragu i upravljanje prozorima na macOS-u.", "searchPlaceholder": "Pretraži prečice...", "searchLabel": "Pretraži prečice na tastaturi", diff --git a/web/messages/da.json b/web/messages/da.json index 936717f60759..4c87f68e8c37 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Tastaturgenveje", "description": "Alle tastaturgenveje tilgængelige i cmux, grupperet efter kategori.", + "sequenceSeparator": "derefter", "metaDescription": "Alle cmux-tastaturgenveje til workspaces, surfaces, opdelte paneler, browser, notifikationer, søgning og vindueshåndtering på macOS.", "searchPlaceholder": "Søg efter genveje...", "searchLabel": "Søg i tastaturgenveje", diff --git a/web/messages/de.json b/web/messages/de.json index 9dceac469966..a1edaf945c41 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Tastaturkürzel", "description": "Alle in cmux verfügbaren Tastaturkürzel, nach Kategorie gruppiert.", + "sequenceSeparator": "dann", "metaDescription": "Alle cmux-Tastaturkürzel für Workspaces, Oberflächen, geteilte Bereiche, Browser, Benachrichtigungen, Suche und Fensterverwaltung unter macOS.", "searchPlaceholder": "Kürzel suchen...", "searchLabel": "Tastaturkürzel durchsuchen", diff --git a/web/messages/en.json b/web/messages/en.json index 96bcf82c3338..2ca05b8cff0f 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1914,6 +1914,7 @@ "keyboardShortcuts": { "title": "Keyboard Shortcuts", "description": "Default cmux keyboard shortcuts. Every cmux-owned shortcut can be changed in Settings or ~/.config/cmux/cmux.json, including two-step chords.", + "sequenceSeparator": "then", "metaTitle": "Keyboard Shortcuts", "metaDescription": "Default cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and app controls on macOS. All cmux-owned shortcuts are configurable in Settings or cmux.json.", "chordsTitle": "Shortcut chords", diff --git a/web/messages/es.json b/web/messages/es.json index 615e9cf06a7d..1e72db58ad41 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Atajos de teclado", "description": "Todos los atajos de teclado disponibles en cmux, agrupados por categoría.", + "sequenceSeparator": "luego", "metaDescription": "Todos los atajos de teclado de cmux para workspaces, superficies, paneles divididos, navegador, notificaciones, búsqueda y gestión de ventanas en macOS.", "searchPlaceholder": "Buscar atajos...", "searchLabel": "Buscar atajos de teclado", diff --git a/web/messages/fr.json b/web/messages/fr.json index 664d448e7a4e..2bc0b49814b2 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1113,6 +1113,7 @@ "keyboardShortcuts": { "title": "Raccourcis clavier", "description": "Tous les raccourcis clavier disponibles dans cmux, classés par catégorie.", + "sequenceSeparator": "puis", "metaDescription": "Tous les raccourcis clavier cmux pour les espaces de travail, surfaces, panneaux divisés, navigateur, notifications, recherche et gestion des fenêtres sur macOS.", "metaDescriptionShort": "Utilisez les raccourcis clavier cmux pour gérer les workspaces, surfaces, panneaux, le navigateur, les notifications, la recherche et les fenêtres macOS.", "searchPlaceholder": "Rechercher des raccourcis...", diff --git a/web/messages/it.json b/web/messages/it.json index 346f01ba944d..e8aab5d9766c 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Scorciatoie da tastiera", "description": "Tutte le scorciatoie da tastiera disponibili in cmux, raggruppate per categoria.", + "sequenceSeparator": "poi", "metaDescription": "Tutte le scorciatoie da tastiera cmux per workspace, superfici, pannelli divisi, browser, notifiche, ricerca e gestione delle finestre su macOS.", "searchPlaceholder": "Cerca scorciatoie...", "searchLabel": "Cerca scorciatoie da tastiera", diff --git a/web/messages/ja.json b/web/messages/ja.json index 9c8716fbf4a9..bf1b6b166274 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1837,6 +1837,7 @@ "keyboardShortcuts": { "title": "キーボードショートカット", "description": "cmuxのデフォルトショートカット一覧です。cmux管理のショートカットはすべて設定画面または ~/.config/cmux/cmux.json で変更でき、2段階のコードにも対応しています。", + "sequenceSeparator": "次に", "chordsTitle": "ショートカットコード", "chordsIntro": "cmux は ~/.config/cmux/cmux.json で2段階のショートカットコードを定義できます。設定ファイル全体の仕様は 設定ドキュメント を参照してください。", "chordsCallout": "ショートカットは設定画面でも編集できますが、tmux 風のプレフィックスを正確に書きたい場合、dotfiles で管理したい場合、または null、空文字列、\"none\"、\"clear\"、\"unbound\"、\"disabled\" でアクションを未割り当てにしたい場合は cmux.json が分かりやすい方法です。", diff --git a/web/messages/km.json b/web/messages/km.json index 4800b9809da2..c2766c73bebd 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "ផ្លូវកាត់ក្ដារចុច", "description": "ផ្លូវកាត់ក្ដារចុចទាំងអស់ដែលមានក្នុង cmux, ដាក់ជាក្រុមតាមប្រភេទ។", + "sequenceSeparator": "បន្ទាប់មក", "metaDescription": "ផ្លូវកាត់ក្ដារចុច cmux ទាំងអស់សម្រាប់ workspace, surface, បន្ទះបំបែក, កម្មវិធីរុករក, ជូនដំណឹង, ស្វែងរក និងការគ្រប់គ្រងបង្អួចនៅលើ macOS។", "searchPlaceholder": "ស្វែងរកផ្លូវកាត់...", "searchLabel": "ស្វែងរកផ្លូវកាត់ក្ដារចុច", diff --git a/web/messages/ko.json b/web/messages/ko.json index 5b722a92abe3..715cb97e5059 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "키보드 단축키", "description": "카테고리별로 정리된 cmux의 모든 키보드 단축키.", + "sequenceSeparator": "다음", "metaDescription": "워크스페이스, 서피스, 분할 패널, 브라우저, 알림, 검색, 창 관리를 위한 cmux의 모든 macOS 키보드 단축키.", "searchPlaceholder": "단축키 검색...", "searchLabel": "키보드 단축키 검색", diff --git a/web/messages/no.json b/web/messages/no.json index 7a90cad661e1..32c44f8c0d93 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Tastatursnarveier", "description": "Alle tastatursnarveier tilgjengelige i cmux, gruppert etter kategori.", + "sequenceSeparator": "deretter", "metaDescription": "Alle cmux-tastatursnarveier for arbeidsområder, overflater, delte paneler, nettleser, varsler, søk og vindusbehandling på macOS.", "searchPlaceholder": "Søk etter snarveier...", "searchLabel": "Søk i tastatursnarveier", diff --git a/web/messages/pl.json b/web/messages/pl.json index 1135f2b7d885..0cbc2dc13ca4 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -1113,6 +1113,7 @@ "keyboardShortcuts": { "title": "Skróty klawiszowe", "description": "Wszystkie skróty klawiszowe dostępne w cmux, pogrupowane według kategorii.", + "sequenceSeparator": "potem", "metaDescription": "Wszystkie skróty klawiszowe cmux dla workspace'ów, surface'ów, dzielonych paneli, przeglądarki, powiadomień, wyszukiwania i zarządzania oknami na macOS.", "searchPlaceholder": "Szukaj skrótów...", "searchLabel": "Szukaj skrótów klawiszowych", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index d99e858c239e..ba40f3a08919 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Atalhos de Teclado", "description": "Todos os atalhos de teclado disponíveis no cmux, agrupados por categoria.", + "sequenceSeparator": "depois", "metaDescription": "Todos os atalhos de teclado do cmux para workspaces, superfícies, painéis divididos, navegador, notificações, busca e gerenciamento de janelas no macOS.", "searchPlaceholder": "Buscar atalhos...", "searchLabel": "Buscar atalhos de teclado", diff --git a/web/messages/ru.json b/web/messages/ru.json index 893e59fa9422..b63fefb32d3d 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Горячие клавиши", "description": "Все горячие клавиши в cmux, сгруппированные по категориям.", + "sequenceSeparator": "затем", "metaDescription": "Все горячие клавиши cmux для рабочих пространств, поверхностей, разделённых панелей, браузера, уведомлений, поиска и управления окнами на macOS.", "searchPlaceholder": "Поиск по горячим клавишам...", "searchLabel": "Поиск горячих клавиш", diff --git a/web/messages/th.json b/web/messages/th.json index 5e457a044891..008593eed737 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -1114,6 +1114,7 @@ "keyboardShortcuts": { "title": "คีย์ลัด", "description": "คีย์ลัดทั้งหมดใน cmux จัดกลุ่มตามหมวดหมู่", + "sequenceSeparator": "แล้ว", "metaDescription": "คีย์ลัด cmux ทั้งหมดสำหรับ workspace, surface, แยกหน้าจอ, เบราว์เซอร์, แจ้งเตือน, ค้นหา และจัดการหน้าต่างบน macOS", "searchPlaceholder": "ค้นหาทางลัด...", "searchLabel": "ค้นหาทางลัดแป้นพิมพ์", diff --git a/web/messages/tr.json b/web/messages/tr.json index f94142f63ff1..d151e2ebe4b5 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Klavye Kısayolları", "description": "cmux'ta mevcut tüm klavye kısayolları, kategoriye göre gruplandırılmış.", + "sequenceSeparator": "sonra", "metaDescription": "macOS'ta çalışma alanları, yüzeyler, bölünmüş paneller, tarayıcı, bildirimler, bulma ve pencere yönetimi için tüm cmux klavye kısayolları.", "searchPlaceholder": "Kısayol ara...", "searchLabel": "Klavye kısayollarını ara", diff --git a/web/messages/uk.json b/web/messages/uk.json index db6517017dfa..f30e033573cf 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "Клавіатурні скорочення", "description": "Усі клавіатурні скорочення cmux, згруповані за категоріями.", + "sequenceSeparator": "потім", "metaTitle": "Клавіатурні скорочення", "metaDescription": "Усі клавіатурні скорочення cmux для робочих просторів, поверхонь, розділених панелей, браузера, сповіщень, пошуку та керування вікнами на macOS.", "searchPlaceholder": "Пошук скорочень...", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 9a9176add087..61ee54f207b3 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "快捷键", "description": "cmux 中所有可用的快捷键,按类别分组。", + "sequenceSeparator": "然后", "metaDescription": "cmux 的所有 macOS 快捷键,涵盖工作区、surface、分屏面板、浏览器、通知、查找和窗口管理。", "metaDescriptionShort": "cmux 的所有 macOS 快捷键,涵盖工作区、surface、分屏面板、浏览器、通知、查找、可配置的键盘操作、搜索流程与窗口导航。", "searchPlaceholder": "搜索快捷键...", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 92cf3211a7ab..34f56b34f496 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -1112,6 +1112,7 @@ "keyboardShortcuts": { "title": "鍵盤快捷鍵", "description": "cmux 中所有可用的鍵盤快捷鍵,依類別分組。", + "sequenceSeparator": "然後", "metaDescription": "cmux 所有鍵盤快捷鍵,涵蓋工作區、surface、分割窗格、瀏覽器、通知、搜尋和 macOS 視窗管理。", "searchPlaceholder": "搜尋快捷鍵...", "searchLabel": "搜尋鍵盤快捷鍵", diff --git a/web/tests/keyboard-shortcuts-data.test.ts b/web/tests/keyboard-shortcuts-data.test.ts new file mode 100644 index 000000000000..bb23d95447fc --- /dev/null +++ b/web/tests/keyboard-shortcuts-data.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { + shortcutCategories, + shortcutSequences, + type ShortcutSequence, +} from "../data/cmux-shortcuts"; + +const shortcutsById = new Map( + shortcutCategories + .flatMap((category) => category.shortcuts) + .map((shortcut) => [shortcut.id, shortcut]), +); + +describe("keyboard shortcut sequence data", () => { + const cases = [ + ["diffViewerScrollToTop", [[["G"], ["G"]]]], + ["diffViewerNextFile", [[["]"], ["F"]]]], + ["diffViewerPreviousFile", [[["["], ["F"]]]], + ] satisfies Array<[string, ShortcutSequence[]]>; + + for (const [id, expectedSequences] of cases) { + test(`${id} exposes its strokes as an ordered chord`, () => { + const shortcut = shortcutsById.get(id); + + expect(shortcut).toBeDefined(); + expect(shortcutSequences(shortcut!)).toEqual(expectedSequences); + }); + } +});