From 2c0dba71d20a228784758790b400937d4e71fdb4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 27 Sep 2026 08:54:00 -0400 Subject: [PATCH 1/3] Add base keymap presets for keyboard shortcuts Settings > Keyboard Shortcuts gets a Base Keymap picker (cmux, iTerm2, Terminal.app, tmux-style) and the Command Palette gets matching "Base Keymap: ..." commands. Choosing a preset writes only the shortcuts.bindings overrides that differ from cmux defaults, removes overrides another preset wrote, and keeps bindings the user set by hand. The result lists each changed shortcut and flags any that macOS claims by default. The preset table, plan, active-preset detection, macOS conflict table and the store write live in CmuxSettings with package tests. Co-Authored-By: Claude Opus 5.5 --- .../JSONConfigStore+ShortcutKeymap.swift | 31 + .../Values/MacOSSystemShortcut.swift | 118 + .../Values/ShortcutKeymapBinding.swift | 65 + .../Values/ShortcutKeymapPlan.swift | 119 + .../Values/ShortcutKeymapPreset.swift | 71 + .../ShortcutKeymapPresetTests.swift | 240 ++ .../CuratedSettingEntry+Default.swift | 1 + .../Sections/KeyboardShortcutsSection.swift | 6 +- .../Sections/ShortcutKeymapPresetRow.swift | 100 + .../Sections/ShortcutKeymapPresetText.swift | 126 + .../SettingsRowAnchorResolutionTests.swift | 1 + Resources/Localizable.xcstrings | 2478 +++++++++++++++++ .../ContentView+KeymapPresetCommands.swift | 66 + Sources/ContentView.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + Sources/SettingsSearchIndex.swift | 1 + cmux.xcodeproj/project.pbxproj | 4 + scripts/localization-allowed-omissions.json | 12 + skills/cmux-keyboard-shortcuts/SKILL.md | 2 + 19 files changed, 3442 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore+ShortcutKeymap.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MacOSSystemShortcut.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapBinding.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPreset.swift create mode 100644 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift create mode 100644 Sources/ContentView+KeymapPresetCommands.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore+ShortcutKeymap.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore+ShortcutKeymap.swift new file mode 100644 index 00000000000..55c55a3f3d5 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore+ShortcutKeymap.swift @@ -0,0 +1,31 @@ +import Foundation + +extension JSONConfigStore { + /// Writes a keymap preset's plan to `shortcuts.bindings`. + /// + /// Each change is one leaf edit, so comments and bindings the plan does + /// not name survive. Removals run first so a failed write never leaves + /// two presets' values mixed on the same action. + /// + /// - Parameters: + /// - plan: The plan from ``ShortcutKeymapPreset/plan(from:defaultShortcutResolver:)``. + /// - bindingsID: The dotted id of the bindings object. + /// - Throws: The first file or parse error; earlier edits stay applied. + public func applyShortcutKeymap( + _ plan: ShortcutKeymapPlan, + bindingsID: String = "shortcuts.bindings" + ) async throws { + let ordered = plan.changes.filter { $0.write == nil } + plan.changes.filter { $0.write != nil } + for change in ordered { + let key = JSONKey( + id: "\(bindingsID).\(change.action.rawValue)", + defaultValue: .unbound + ) + if let binding = change.write { + try await set(binding, for: key) + } else { + try await reset(key) + } + } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MacOSSystemShortcut.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MacOSSystemShortcut.swift new file mode 100644 index 00000000000..da79b8bf4ff --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MacOSSystemShortcut.swift @@ -0,0 +1,118 @@ +import Foundation + +/// A keystroke macOS claims by default before cmux sees it. +/// +/// The list covers shortcuts enabled out of the box in System Settings > +/// Keyboard > Keyboard Shortcuts plus the system screenshot and window +/// switcher keys. It does not read the user's own System Settings changes. +public enum MacOSSystemShortcut: String, CaseIterable, Sendable { + case spotlight + case finderSearch + case previousInputSource + case nextInputSource + case appSwitcher + case appSwitcherReverse + case nextWindow + case previousWindow + case missionControl + case applicationWindows + case spaceLeft + case spaceRight + case screenshotScreen + case screenshotSelection + case screenshotToolbar + case screenshotScreenToClipboard + case screenshotSelectionToClipboard + case toggleDockHiding + case lockScreen + case logOut + case characterViewer + case lookUp + case helpMenuSearch + case focusMenuBar + case focusDock + case voiceOver + case accessibilityShortcuts + + /// The keystroke macOS reserves. + public var stroke: ShortcutStroke { + switch self { + case .spotlight: return ShortcutStroke(key: "space", command: true) + case .finderSearch: return ShortcutStroke(key: "space", command: true, option: true) + case .previousInputSource: return ShortcutStroke(key: "space", control: true) + case .nextInputSource: return ShortcutStroke(key: "space", option: true, control: true) + case .appSwitcher: return ShortcutStroke(key: "\t", command: true) + case .appSwitcherReverse: return ShortcutStroke(key: "\t", command: true, shift: true) + case .nextWindow: return ShortcutStroke(key: "`", command: true) + case .previousWindow: return ShortcutStroke(key: "`", command: true, shift: true) + case .missionControl: return ShortcutStroke(key: "↑", control: true) + case .applicationWindows: return ShortcutStroke(key: "↓", control: true) + case .spaceLeft: return ShortcutStroke(key: "←", control: true) + case .spaceRight: return ShortcutStroke(key: "→", control: true) + case .screenshotScreen: return ShortcutStroke(key: "3", command: true, shift: true) + case .screenshotSelection: return ShortcutStroke(key: "4", command: true, shift: true) + case .screenshotToolbar: return ShortcutStroke(key: "5", command: true, shift: true) + case .screenshotScreenToClipboard: + return ShortcutStroke(key: "3", command: true, shift: true, control: true) + case .screenshotSelectionToClipboard: + return ShortcutStroke(key: "4", command: true, shift: true, control: true) + case .toggleDockHiding: return ShortcutStroke(key: "d", command: true, option: true) + case .lockScreen: return ShortcutStroke(key: "q", command: true, control: true) + case .logOut: return ShortcutStroke(key: "q", command: true, shift: true) + case .characterViewer: return ShortcutStroke(key: "space", command: true, control: true) + case .lookUp: return ShortcutStroke(key: "d", command: true, control: true) + case .helpMenuSearch: return ShortcutStroke(key: "/", command: true, shift: true) + case .focusMenuBar: return ShortcutStroke(key: "f2", control: true) + case .focusDock: return ShortcutStroke(key: "f3", control: true) + case .voiceOver: return ShortcutStroke(key: "f5", command: true) + case .accessibilityShortcuts: return ShortcutStroke(key: "f5", command: true, option: true) + } + } + + /// The macOS shortcuts a binding would collide with. + /// + /// Both strokes of a chord are checked, since macOS intercepts either one. + /// For a numbered action (`usesNumberedDigitMatching`), the binding stands + /// for its whole `1…9` family, so `cmd+shift+1` collides with the + /// `cmd+shift+3` screenshot. + /// + /// - Parameters: + /// - shortcut: The binding to check. + /// - action: The action that owns the binding. + /// - Returns: The colliding macOS shortcuts in declaration order. + public static func conflicts( + with shortcut: StoredShortcut, + for action: ShortcutAction + ) -> [MacOSSystemShortcut] { + guard !shortcut.isUnbound else { return [] } + let strokes = [shortcut.first] + (shortcut.second.map { [$0] } ?? []) + let numberedStroke = shortcut.second ?? shortcut.first + let candidates = strokes.flatMap { stroke -> [ShortcutStroke] in + guard action.usesNumberedDigitMatching, stroke == numberedStroke else { + return [stroke] + } + return (1...9).map { digit in + ShortcutStroke( + key: String(digit), + command: stroke.command, + shift: stroke.shift, + option: stroke.option, + control: stroke.control + ) + } + } + return allCases.filter { reserved in + candidates.contains { $0.matchesIgnoringKeyCode(reserved.stroke) } + } + } +} + +private extension ShortcutStroke { + func matchesIgnoringKeyCode(_ other: ShortcutStroke) -> Bool { + canonicalized().key == other.canonicalized().key + && command == other.command + && shift == other.shift + && option == other.option + && control == other.control + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapBinding.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapBinding.swift new file mode 100644 index 00000000000..2db7d9824b4 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapBinding.swift @@ -0,0 +1,65 @@ +import Foundation + +/// One `shortcuts.bindings` value in its hand-editable `cmux.json` form: +/// `"cmd+d"`, `["ctrl+b", "c"]`, or `null`. +/// +/// Keymap presets write this form instead of the structured +/// ``StoredShortcut`` encoding so the file stays readable, and compare +/// through ``shortcut`` so either form on disk matches. +public enum ShortcutKeymapBinding: Sendable, Hashable, SettingCodable { + /// A single stroke such as `"cmd+shift+c"`. + case stroke(String) + /// A prefix stroke followed by a second stroke, such as `["ctrl+b", "c"]`. + case chord(String, String) + /// An explicit unbind (`null`). + case unbound + + /// The parsed binding, or `nil` when the text is not a valid shortcut. + public var shortcut: StoredShortcut? { + switch self { + case .stroke(let stroke): + return StoredShortcut.parseConfig(stroke, allowBareFirstStroke: true)?.canonicalized() + case .chord(let prefix, let second): + return StoredShortcut.parseConfig( + strokes: [prefix, second], + allowBareFirstStroke: true + )?.canonicalized() + case .unbound: + return .unbound + } + } + + // MARK: - SettingCodable + + public static func decodeFromUserDefaults(_ raw: Any?) -> ShortcutKeymapBinding? { + decodeFromJSON(raw) + } + + public func encodeForUserDefaults() -> Any { + encodeForJSON() + } + + public static func decodeFromJSON(_ raw: Any?) -> ShortcutKeymapBinding? { + if raw is NSNull { return .unbound } + if let stroke = raw as? String { return .stroke(stroke) } + if let strokes = raw as? [String] { + switch strokes.count { + case 1: return .stroke(strokes[0]) + case 2: return .chord(strokes[0], strokes[1]) + default: return nil + } + } + return nil + } + + public func encodeForJSON() -> Any { + switch self { + case .stroke(let stroke): + return stroke + case .chord(let prefix, let second): + return [prefix, second] + case .unbound: + return NSNull() + } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift new file mode 100644 index 00000000000..d98c6c7cfc4 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift @@ -0,0 +1,119 @@ +import Foundation + +/// One shortcut a keymap preset changes. +public struct ShortcutKeymapChange: Sendable, Equatable { + /// The action whose binding changes. + public let action: ShortcutAction + /// The effective binding before the change. ``StoredShortcut/unbound`` when none. + public let before: StoredShortcut + /// The effective binding after the change. ``StoredShortcut/unbound`` when none. + public let after: StoredShortcut + /// The value written to `shortcuts.bindings`, or `nil` when the change + /// removes an override so the cmux default applies again. + public let write: ShortcutKeymapBinding? + /// macOS shortcuts that claim ``after`` before cmux sees it. + public let systemConflicts: [MacOSSystemShortcut] +} + +/// The `shortcuts.bindings` edits that switch the file to a keymap preset. +/// +/// A preset only replaces overrides that are absent or that another preset +/// wrote. A binding the user set by hand stays and is listed in ``kept``, so +/// switching presets never discards the user's own edits. +public struct ShortcutKeymapPlan: Sendable, Equatable { + /// The preset this plan switches to. + public let preset: ShortcutKeymapPreset + /// Every binding that changes, in ``ShortcutAction`` declaration order. + public let changes: [ShortcutKeymapChange] + /// Actions the preset would change but the user customized, left alone. + public let kept: [ShortcutAction] + + /// True when applying the plan would not touch the file. + public var isEmpty: Bool { changes.isEmpty } + + /// Changes whose new binding collides with a macOS shortcut. + public var systemConflicts: [ShortcutKeymapChange] { + changes.filter { !$0.systemConflicts.isEmpty } + } +} + +extension ShortcutKeymapPreset { + /// Computes the edits that switch `snapshot` to this preset. + /// + /// - Parameters: + /// - snapshot: The current `shortcuts.bindings` contents of `cmux.json`. + /// - defaultShortcutResolver: The host's factory defaults, used to show + /// effective before/after values and to skip writes that equal a default. + /// - Returns: The overrides to write or remove, plus the user bindings kept. + public func plan( + from snapshot: ShortcutBindingsSnapshot, + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn + ) -> ShortcutKeymapPlan { + let targetOverrides = overrides + var changes: [ShortcutKeymapChange] = [] + var kept: [ShortcutAction] = [] + + for action in ShortcutAction.allCases { + let current = snapshot.bindings[action.rawValue]?.canonicalized() + let managed = current != nil || snapshot.managedActionIDs.contains(action.rawValue) + let defaultShortcut = (action.defaultShortcut(using: defaultShortcutResolver) ?? .unbound) + .canonicalized() + let before = managed ? (current ?? defaultShortcut) : defaultShortcut + let ownedByPreset = current.map { Self.isPresetValue($0, for: action) } ?? false + + if let binding = targetOverrides[action], let target = binding.shortcut { + guard current != target else { continue } + guard !managed || ownedByPreset else { + kept.append(action) + continue + } + if !managed && target == defaultShortcut { continue } + changes.append(ShortcutKeymapChange( + action: action, + before: before, + after: target, + write: binding, + systemConflicts: MacOSSystemShortcut.conflicts(with: target, for: action) + )) + } else if ownedByPreset { + changes.append(ShortcutKeymapChange( + action: action, + before: before, + after: defaultShortcut, + write: nil, + systemConflicts: MacOSSystemShortcut.conflicts(with: defaultShortcut, for: action) + )) + } + } + + return ShortcutKeymapPlan(preset: self, changes: changes, kept: kept) + } + + /// The preset `snapshot` is currently on, or `nil` when the file mixes + /// presets. + /// + /// A preset is active when switching to it would change nothing and at + /// least one of its overrides is in the file. Hand-made edits to other + /// actions, or to a preset's own actions, don't hide the preset. + public static func active( + in snapshot: ShortcutBindingsSnapshot, + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn + ) -> ShortcutKeymapPreset? { + for preset in allCases where preset != .cmux { + let applied = preset.overrides.contains { action, binding in + snapshot.bindings[action.rawValue]?.canonicalized() == binding.shortcut + } + if applied, + preset.plan(from: snapshot, defaultShortcutResolver: defaultShortcutResolver).isEmpty { + return preset + } + } + return ShortcutKeymapPreset.cmux + .plan(from: snapshot, defaultShortcutResolver: defaultShortcutResolver) + .isEmpty ? .cmux : nil + } + + private static func isPresetValue(_ shortcut: StoredShortcut, for action: ShortcutAction) -> Bool { + allCases.contains { $0.overrides[action]?.shortcut == shortcut } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPreset.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPreset.swift new file mode 100644 index 00000000000..04152f9d8e8 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPreset.swift @@ -0,0 +1,71 @@ +import Foundation + +/// A base keymap for people coming from another terminal, like Zed's base +/// keymap picker. +/// +/// A preset is a list of `shortcuts.bindings` overrides. Every override +/// differs from the cmux default, so the ``cmux`` preset has none and choosing +/// it removes the overrides another preset wrote. Presets map only actions +/// cmux has; a terminal's shortcut that already matches cmux (for example +/// iTerm2's Cmd-D, Cmd-Shift-D, Cmd-Opt-arrows, Cmd-Shift-Return and Cmd-T) +/// needs no override. +public enum ShortcutKeymapPreset: String, CaseIterable, Sendable { + /// cmux's built-in shortcuts. + case cmux + /// iTerm2's defaults: Cmd-1…9 selects tabs, Cmd-Opt-1…9 selects windows, + /// Cmd-Shift-C enters copy mode, and Cmd-Ctrl-arrows move pane dividers. + case iTerm2 = "iterm2" + /// Terminal.app's defaults: Cmd-Opt-W closes other tabs and Cmd-Shift-I + /// edits the tab title. + case terminal + /// tmux's default `ctrl+b` prefix, with tmux windows mapped to cmux + /// workspaces and tmux panes to cmux panes. + case tmux + + /// The `shortcuts.bindings` values this preset writes, keyed by action. + /// + /// Values use the hand-editable `cmux.json` forms so the file stays + /// readable after a preset is applied. + public var overrides: [ShortcutAction: ShortcutKeymapBinding] { + switch self { + case .cmux: + return [:] + case .iTerm2: + return [ + .selectSurfaceByNumber: .stroke("cmd+1"), + .selectWorkspaceByNumber: .stroke("cmd+opt+1"), + .toggleTerminalCopyMode: .stroke("cmd+shift+c"), + .resizePaneLeft: .stroke("cmd+ctrl+left"), + .resizePaneRight: .stroke("cmd+ctrl+right"), + .resizePaneUp: .stroke("cmd+ctrl+up"), + .resizePaneDown: .stroke("cmd+ctrl+down"), + ] + case .terminal: + return [ + .closeOtherTabsInPane: .stroke("cmd+opt+w"), + .renameTab: .stroke("cmd+shift+i"), + ] + case .tmux: + let prefix = "ctrl+b" + return [ + .newTab: .chord(prefix, "c"), + .closeTab: .chord(prefix, "x"), + .closeWorkspace: .chord(prefix, "shift+7"), + .nextSidebarTab: .chord(prefix, "n"), + .prevSidebarTab: .chord(prefix, "p"), + .selectWorkspaceByNumber: .chord(prefix, "1"), + .renameWorkspace: .chord(prefix, ","), + .goToWorkspace: .chord(prefix, "w"), + .splitRight: .chord(prefix, "shift+5"), + .splitDown: .chord(prefix, "shift+'"), + .focusLeft: .chord(prefix, "left"), + .focusRight: .chord(prefix, "right"), + .focusUp: .chord(prefix, "up"), + .focusDown: .chord(prefix, "down"), + .focusNextPane: .chord(prefix, "o"), + .toggleSplitZoom: .chord(prefix, "z"), + .toggleTerminalCopyMode: .chord(prefix, "["), + ] + } + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift new file mode 100644 index 00000000000..f7f0c9dd6a4 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing + +@testable import CmuxSettings + +@Suite("Shortcut keymap presets") +struct ShortcutKeymapPresetTests { + private func snapshot(_ bindings: [ShortcutAction: StoredShortcut]) -> ShortcutBindingsSnapshot { + ShortcutBindingsSnapshot( + bindings: Dictionary(uniqueKeysWithValues: bindings.map { ($0.key.rawValue, $0.value) }), + managedActionIDs: Set(bindings.keys.map(\.rawValue)) + ) + } + + private func applied(_ preset: ShortcutKeymapPreset) -> [ShortcutAction: StoredShortcut] { + preset.overrides.compactMapValues(\.shortcut) + } + + private let empty = ShortcutBindingsSnapshot(bindings: [:], managedActionIDs: []) + + @Test(arguments: ShortcutKeymapPreset.allCases) + func everyOverrideIsValidAndDiffersFromTheDefault(preset: ShortcutKeymapPreset) throws { + for (action, binding) in preset.overrides { + let shortcut = try #require(binding.shortcut, "\(preset) \(action) does not parse") + #expect(action.shortcutBindingPolicyResult(for: shortcut) == .accepted, "\(preset) \(action)") + #expect(shortcut != action.defaultShortcut?.canonicalized(), "\(preset) \(action) repeats the default") + } + } + + @Test func cmuxPresetWritesNothing() { + #expect(ShortcutKeymapPreset.cmux.overrides.isEmpty) + #expect(ShortcutKeymapPreset.cmux.plan(from: empty).isEmpty) + } + + @Test func applyingToDefaultsWritesOnlyThePresetOverrides() throws { + let plan = ShortcutKeymapPreset.iTerm2.plan(from: empty) + + #expect(Set(plan.changes.map(\.action)) == Set(ShortcutKeymapPreset.iTerm2.overrides.keys)) + #expect(plan.kept.isEmpty) + let copyMode = try #require(plan.changes.first { $0.action == .toggleTerminalCopyMode }) + #expect(copyMode.write == .stroke("cmd+shift+c")) + #expect(copyMode.before == StoredShortcut(first: ShortcutStroke(key: "m", command: true, shift: true))) + #expect(copyMode.after == StoredShortcut(first: ShortcutStroke(key: "c", command: true, shift: true))) + } + + @Test(arguments: ShortcutKeymapPreset.allCases.filter { $0 != .cmux }) + func switchingBackToCmuxRemovesEveryOverride(preset: ShortcutKeymapPreset) { + let plan = ShortcutKeymapPreset.cmux.plan(from: snapshot(applied(preset))) + + #expect(Set(plan.changes.map(\.action)) == Set(preset.overrides.keys)) + #expect(plan.changes.allSatisfy { $0.write == nil }) + #expect(plan.changes.allSatisfy { $0.after == ($0.action.defaultShortcut ?? .unbound) }) + } + + @Test func switchingPresetsReplacesTheOtherPresetsValues() throws { + let plan = ShortcutKeymapPreset.tmux.plan(from: snapshot(applied(.iTerm2))) + + let copyMode = try #require(plan.changes.first { $0.action == .toggleTerminalCopyMode }) + #expect(copyMode.before == ShortcutKeymapBinding.stroke("cmd+shift+c").shortcut) + #expect(copyMode.write == .chord("ctrl+b", "[")) + let resize = try #require(plan.changes.first { $0.action == .resizePaneLeft }) + #expect(resize.write == nil) + #expect(plan.changes.contains { $0.action == .selectSurfaceByNumber && $0.write == nil }) + #expect(plan.changes.contains { $0.action == .newTab && $0.write == .chord("ctrl+b", "c") }) + } + + @Test func userBindingsSurviveEveryPreset() { + let custom = StoredShortcut(first: ShortcutStroke(key: "x", command: true, shift: true)) + let unrelated = StoredShortcut(first: ShortcutStroke(key: "k", command: true, option: true)) + var bindings = applied(.iTerm2) + bindings[.toggleTerminalCopyMode] = custom + bindings[.openFolder] = unrelated + + let toTmux = ShortcutKeymapPreset.tmux.plan(from: snapshot(bindings)) + #expect(toTmux.kept.contains(.toggleTerminalCopyMode)) + #expect(!toTmux.changes.contains { $0.action == .toggleTerminalCopyMode || $0.action == .openFolder }) + + let toCmux = ShortcutKeymapPreset.cmux.plan(from: snapshot(bindings)) + #expect(!toCmux.changes.contains { $0.action == .toggleTerminalCopyMode || $0.action == .openFolder }) + #expect(toCmux.changes.contains { $0.action == .resizePaneLeft }) + } + + @Test func malformedManagedBindingCountsAsACustomization() { + let managed = ShortcutBindingsSnapshot(bindings: [:], managedActionIDs: ["toggleTerminalCopyMode"]) + + let plan = ShortcutKeymapPreset.iTerm2.plan(from: managed) + + #expect(plan.kept == [.toggleTerminalCopyMode]) + } + + @Test func activePresetFollowsTheFile() { + #expect(ShortcutKeymapPreset.active(in: empty) == .cmux) + #expect(ShortcutKeymapPreset.active(in: snapshot(applied(.iTerm2))) == .iTerm2) + #expect(ShortcutKeymapPreset.active(in: snapshot(applied(.tmux))) == .tmux) + + var customized = applied(.iTerm2) + customized[.toggleTerminalCopyMode] = StoredShortcut(first: ShortcutStroke(key: "x", command: true, shift: true)) + #expect(ShortcutKeymapPreset.active(in: snapshot(customized)) == .iTerm2) + + var mixed = applied(.terminal) + mixed[.resizePaneLeft] = ShortcutKeymapBinding.stroke("cmd+ctrl+left").shortcut + #expect(ShortcutKeymapPreset.active(in: snapshot(mixed)) == nil) + } + + @Test(arguments: ShortcutKeymapPreset.allCases) + func presetsIntroduceNoCmuxConflicts(preset: ShortcutKeymapPreset) { + var effective: [ShortcutAction: StoredShortcut] = [:] + for action in ShortcutAction.allCases { + effective[action] = action.defaultShortcut + } + for (action, shortcut) in applied(preset) { + effective[action] = shortcut + } + + for overridden in preset.overrides.keys { + guard let shortcut = effective[overridden] else { continue } + for (other, otherShortcut) in effective where other != overridden { + guard Self.overlaps(shortcut, overridden, otherShortcut, other) else { continue } + let collides = ShortcutWhenClause.bindingsCollide( + overridden.defaultFocusWhenClause, + lhsHasPriority: overridden.hasPriorityShortcutRouting, + other.defaultFocusWhenClause, + rhsHasPriority: other.hasPriorityShortcutRouting + ) + #expect(!collides, "\(preset): \(overridden) collides with \(other)") + } + } + } + + @Test(arguments: ShortcutKeymapPreset.allCases) + func presetsShipWithoutMacOSConflicts(preset: ShortcutKeymapPreset) { + #expect(preset.plan(from: empty).systemConflicts.isEmpty) + } + + /// Whether two bindings can fire on the same keystroke sequence. A chord + /// overlaps a single stroke equal to its prefix; numbered actions stand for + /// their `1…9` family. + private static func overlaps( + _ lhs: StoredShortcut, + _ lhsAction: ShortcutAction, + _ rhs: StoredShortcut, + _ rhsAction: ShortcutAction + ) -> Bool { + guard !lhs.isUnbound, !rhs.isUnbound else { return false } + return expanded(lhs, lhsAction).contains { left in + expanded(rhs, rhsAction).contains { right in + left.first == right.first + && (left.second == nil || right.second == nil || left.second == right.second) + } + } + } + + private static func expanded(_ shortcut: StoredShortcut, _ action: ShortcutAction) -> [StoredShortcut] { + let canonical = shortcut.canonicalized() + guard action.usesNumberedDigitMatching else { return [canonical] } + return (1...9).map { digit in + let stroke = canonical.second ?? canonical.first + let digitStroke = ShortcutStroke( + key: String(digit), + command: stroke.command, + shift: stroke.shift, + option: stroke.option, + control: stroke.control + ) + return canonical.second == nil + ? StoredShortcut(first: digitStroke) + : StoredShortcut(first: canonical.first, second: digitStroke) + } + } +} + +@Suite("macOS system shortcut conflicts") +struct MacOSSystemShortcutTests { + @Test func flagsASingleStrokeMacOSOwns() { + let spotlight = StoredShortcut(first: ShortcutStroke(key: "space", command: true)) + #expect(MacOSSystemShortcut.conflicts(with: spotlight, for: .commandPalette) == [.spotlight]) + + let dock = StoredShortcut(first: ShortcutStroke(key: "D", command: true, option: true)) + #expect(MacOSSystemShortcut.conflicts(with: dock, for: .splitBrowserRight) == [.toggleDockHiding]) + } + + @Test func ignoresShortcutsMacOSLeavesAlone() { + let split = StoredShortcut(first: ShortcutStroke(key: "d", command: true)) + #expect(MacOSSystemShortcut.conflicts(with: split, for: .splitRight).isEmpty) + #expect(MacOSSystemShortcut.conflicts(with: .unbound, for: .splitRight).isEmpty) + } + + @Test func checksBothStrokesOfAChord() { + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "←", control: true) + ) + #expect(MacOSSystemShortcut.conflicts(with: chord, for: .focusLeft) == [.spaceLeft]) + } + + @Test func expandsNumberedActionsToTheirDigitFamily() { + let family = StoredShortcut(first: ShortcutStroke(key: "1", command: true, shift: true)) + + #expect(MacOSSystemShortcut.conflicts(with: family, for: .selectWorkspaceByNumber) + == [.screenshotScreen, .screenshotSelection, .screenshotToolbar]) + #expect(MacOSSystemShortcut.conflicts(with: family, for: .openFolder).isEmpty) + } +} + +@Suite("Keymap presets written through JSONConfigStore") +struct ShortcutKeymapStoreTests { + @Test func applyWritesReadableOverridesAndSwitchingBackRemovesThem() async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("cmux.json") + try Data(""" + { + // keep this comment + "app": { "appearance": "dark" }, + "shortcuts": { "bindings": { "openFolder": "cmd+opt+k" } } + } + + """.utf8).write(to: file) + let store = JSONConfigStore(fileURL: file) + let bindingsKey = SettingCatalog().shortcuts.bindingSnapshot + + let toTmux = ShortcutKeymapPreset.tmux.plan(from: await store.value(for: bindingsKey)) + try await store.applyShortcutKeymap(toTmux) + + let text = try String(contentsOf: file, encoding: .utf8) + #expect(text.contains("// keep this comment")) + #expect(text.contains("\"ctrl+b\"")) + let afterTmux = await store.value(for: bindingsKey) + #expect(ShortcutKeymapPreset.active(in: afterTmux) == .tmux) + #expect(afterTmux.bindings["openFolder"] == ShortcutKeymapBinding.stroke("cmd+opt+k").shortcut) + + try await store.applyShortcutKeymap(ShortcutKeymapPreset.cmux.plan(from: afterTmux)) + + let afterCmux = await store.value(for: bindingsKey) + #expect(afterCmux.managedActionIDs == ["openFolder"]) + #expect(ShortcutKeymapPreset.active(in: afterCmux) == .cmux) + #expect(try String(contentsOf: file, encoding: .utf8).contains("\"appearance\"")) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index bc1329e73a2..db02c26e6f5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -512,6 +512,7 @@ extension Array where Element == CuratedSettingEntry { ].joined(separator: " ") ), .init(section: .keyboardShortcuts, id: "modifier-hold-hints", title: String(localized: "settings.shortcuts.showModifierHoldHints", defaultValue: "Show Shortcut Hints While Holding Modifier Keys"), synonyms: "Show Shortcut Hints While Holding Modifier Keys shortcuts.showModifierHoldHints shortcut hints hotkey hints command cmd modifier hold chips badges"), + .init(section: .keyboardShortcuts, id: "base-keymap", title: String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), synonyms: "Base Keymap preset keybindings iterm iterm2 terminal app terminal.app tmux prefix ctrl-b coming from switch shortcuts macos conflicts"), .init(section: .keyboardShortcuts, id: "shortcut-chords", title: String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), synonyms: "Shortcut Chords tmux prefix ctrl-b control-b multi key sequence chord cmux json"), .init(section: .keyboardShortcuts, id: "reset-defaults", title: String(localized: "settings.shortcuts.resetDefaults", defaultValue: "Reset Default Shortcuts"), synonyms: "Reset Default Shortcuts reset restore default defaults built in builtin shortcuts hotkeys keybindings commands"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index f398ce75f5b..08a4a61f272 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -3,8 +3,8 @@ import CmuxSettings import SwiftUI /// **Keyboard Shortcuts** section — mirrors the legacy in-app -/// section: one `SettingsCard` containing the chord docs link, -/// the Reset Defaults action, and a per-action recorder row for +/// section: one `SettingsCard` containing the base keymap picker, +/// the chord docs link, the Reset Defaults action, and a per-action recorder row for /// every `ShortcutAction` (using the new package recorder). @MainActor public struct KeyboardShortcutsSection: View { @@ -53,6 +53,8 @@ public struct KeyboardShortcutsSection: View { SettingsSectionHeader(String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), section: .keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { + ShortcutKeymapPresetRow(model: model) + SettingsCardDivider() chordsRow SettingsCardDivider() ModifierHoldHintsSettingsRow() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift new file mode 100644 index 00000000000..947d115151d --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift @@ -0,0 +1,100 @@ +import CmuxFoundation +import CmuxSettings +import SwiftUI + +/// The **Base Keymap** picker: switches `shortcuts.bindings` to a preset and +/// lists what changed, including shortcuts macOS may take first. +@MainActor +struct ShortcutKeymapPresetRow: View { + let model: ShortcutListModel + @State private var lastPlan: ShortcutKeymapPlan? + @State private var isApplying = false + + private var snapshot: ShortcutBindingsSnapshot { + ShortcutBindingsSnapshot( + bindings: model.latestBindings, + managedActionIDs: model.managedBindingActionIDs + ) + } + + private var activePreset: ShortcutKeymapPreset? { + ShortcutKeymapPreset.active(in: snapshot, defaultShortcutResolver: model.defaultShortcutResolver) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsCardRow( + configurationReview: .json("shortcuts.bindings"), + searchAnchorID: "setting:keyboardShortcuts:base-keymap", + String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), + subtitle: String( + localized: "settings.shortcuts.baseKeymap.subtitle", + defaultValue: "Start from another terminal's shortcuts. Only the differences are written to cmux.json; choose cmux to remove them." + ), + controlWidth: 220 + ) { + Picker( + String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), + selection: Binding( + get: { activePreset }, + set: { preset in + if let preset { apply(preset) } + } + ) + ) { + ForEach(ShortcutKeymapPreset.allCases, id: \.self) { preset in + Text(preset.displayName).tag(Optional(preset)) + } + if activePreset == nil { + Text(String(localized: "settings.shortcuts.baseKeymap.custom", defaultValue: "Custom")) + .tag(ShortcutKeymapPreset?.none) + } + } + .labelsHidden() + .pickerStyle(.menu) + .disabled(isApplying) + .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapPicker") + } + if let lastPlan { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(ShortcutKeymapPlanText.lines(for: lastPlan).enumerated()), id: \.offset) { _, line in + Text(line) + .cmuxFont(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + if !lastPlan.systemConflicts.isEmpty { + Label( + String( + localized: "settings.shortcuts.baseKeymap.conflictHint", + defaultValue: "Change or turn off the macOS shortcut in System Settings > Keyboard > Keyboard Shortcuts." + ), + systemImage: "exclamationmark.triangle" + ) + .cmuxFont(.caption) + .foregroundColor(.orange) + } + } + .padding(.horizontal, 14) + .padding(.bottom, 9) + .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapSummary") + } + } + } + + private func apply(_ preset: ShortcutKeymapPreset) { + let plan = preset.plan(from: snapshot, defaultShortcutResolver: model.defaultShortcutResolver) + lastPlan = plan + guard !plan.isEmpty else { return } + isApplying = true + Task { + defer { isApplying = false } + do { + try await model.jsonStore.applyShortcutKeymap(plan, bindingsID: model.catalog.shortcuts.bindings.id) + model.onShortcutsChanged() + } catch { + model.errorLog.record(error, keyID: model.catalog.shortcuts.bindings.id) + } + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift new file mode 100644 index 00000000000..4b5645c7f57 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift @@ -0,0 +1,126 @@ +import CmuxSettings +import Foundation + +extension ShortcutKeymapPreset { + /// The name shown in the Base Keymap picker and the Command Palette. + public var displayName: String { + switch self { + case .cmux: + return String(localized: "shortcut.keymap.preset.cmux", defaultValue: "cmux (Default)") + case .iTerm2: + return String(localized: "shortcut.keymap.preset.iterm2", defaultValue: "iTerm2") + case .terminal: + return String(localized: "shortcut.keymap.preset.terminal", defaultValue: "Terminal.app") + case .tmux: + return String(localized: "shortcut.keymap.preset.tmux", defaultValue: "tmux-style (Ctrl-B Prefix)") + } + } +} + +extension MacOSSystemShortcut { + /// The System Settings name of the macOS shortcut. + public var displayName: String { + switch self { + case .spotlight: + return String(localized: "shortcut.macos.spotlight", defaultValue: "Show Spotlight search") + case .finderSearch: + return String(localized: "shortcut.macos.finderSearch", defaultValue: "Show Finder search window") + case .previousInputSource: + return String(localized: "shortcut.macos.previousInputSource", defaultValue: "Select the previous input source") + case .nextInputSource: + return String(localized: "shortcut.macos.nextInputSource", defaultValue: "Select the next input source") + case .appSwitcher: + return String(localized: "shortcut.macos.appSwitcher", defaultValue: "Switch apps") + case .appSwitcherReverse: + return String(localized: "shortcut.macos.appSwitcherReverse", defaultValue: "Switch apps backward") + case .nextWindow: + return String(localized: "shortcut.macos.nextWindow", defaultValue: "Move focus to the next window") + case .previousWindow: + return String(localized: "shortcut.macos.previousWindow", defaultValue: "Move focus to the previous window") + case .missionControl: + return String(localized: "shortcut.macos.missionControl", defaultValue: "Show Mission Control") + case .applicationWindows: + return String(localized: "shortcut.macos.applicationWindows", defaultValue: "Show application windows") + case .spaceLeft: + return String(localized: "shortcut.macos.spaceLeft", defaultValue: "Move left a space") + case .spaceRight: + return String(localized: "shortcut.macos.spaceRight", defaultValue: "Move right a space") + case .screenshotScreen: + return String(localized: "shortcut.macos.screenshotScreen", defaultValue: "Save picture of screen as a file") + case .screenshotSelection: + return String(localized: "shortcut.macos.screenshotSelection", defaultValue: "Save picture of selected area as a file") + case .screenshotToolbar: + return String(localized: "shortcut.macos.screenshotToolbar", defaultValue: "Screenshot and recording options") + case .screenshotScreenToClipboard: + return String(localized: "shortcut.macos.screenshotScreenToClipboard", defaultValue: "Copy picture of screen to the clipboard") + case .screenshotSelectionToClipboard: + return String(localized: "shortcut.macos.screenshotSelectionToClipboard", defaultValue: "Copy picture of selected area to the clipboard") + case .toggleDockHiding: + return String(localized: "shortcut.macos.toggleDockHiding", defaultValue: "Turn Dock hiding on or off") + case .lockScreen: + return String(localized: "shortcut.macos.lockScreen", defaultValue: "Lock Screen") + case .logOut: + return String(localized: "shortcut.macos.logOut", defaultValue: "Log Out") + case .characterViewer: + return String(localized: "shortcut.macos.characterViewer", defaultValue: "Show Emoji & Symbols") + case .lookUp: + return String(localized: "shortcut.macos.lookUp", defaultValue: "Look up & data detectors") + case .helpMenuSearch: + return String(localized: "shortcut.macos.helpMenuSearch", defaultValue: "Show Help menu") + case .focusMenuBar: + return String(localized: "shortcut.macos.focusMenuBar", defaultValue: "Move focus to the menu bar") + case .focusDock: + return String(localized: "shortcut.macos.focusDock", defaultValue: "Move focus to the Dock") + case .voiceOver: + return String(localized: "shortcut.macos.voiceOver", defaultValue: "Turn VoiceOver on or off") + case .accessibilityShortcuts: + return String(localized: "shortcut.macos.accessibilityShortcuts", defaultValue: "Show Accessibility Shortcuts") + } + } +} + +/// Plain-text lines describing a keymap preset switch, shared by the Settings +/// row and the Command Palette confirmation. +public enum ShortcutKeymapPlanText { + /// One line per changed shortcut, then one per macOS conflict, then one per + /// user binding the preset kept. + public static func lines(for plan: ShortcutKeymapPlan) -> [String] { + guard !plan.isEmpty || !plan.kept.isEmpty else { + return [String(localized: "shortcut.keymap.summary.none", defaultValue: "No shortcuts changed.")] + } + let changeFormat = String(localized: "shortcut.keymap.summary.change", defaultValue: "%1$@: %2$@ → %3$@") + let conflictFormat = String( + localized: "shortcut.keymap.summary.conflict", + defaultValue: "%1$@ is also a macOS shortcut (%2$@). macOS may handle it before cmux." + ) + let keptFormat = String( + localized: "shortcut.keymap.summary.kept", + defaultValue: "Kept your own shortcut for %@." + ) + var lines = plan.changes.map { change in + String.localizedStringWithFormat( + changeFormat, + change.action.displayName, + display(change.before, for: change.action), + display(change.after, for: change.action) + ) + } + for change in plan.systemConflicts { + let names = change.systemConflicts.map(\.displayName).joined(separator: ", ") + lines.append(String.localizedStringWithFormat( + conflictFormat, + display(change.after, for: change.action), + names + )) + } + lines += plan.kept.map { String.localizedStringWithFormat(keptFormat, $0.displayName) } + return lines + } + + private static func display(_ shortcut: StoredShortcut, for action: ShortcutAction) -> String { + guard !shortcut.isUnbound else { + return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + } + return shortcutDisplayString(shortcut, numbered: action.usesNumberedDigitMatching) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index b1a60a19968..53541192511 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -194,6 +194,7 @@ struct SettingsRowAnchorResolutionTests { "setting:globalHotkey:enable-hotkey", "setting:globalHotkey:shortcut", "setting:keyboardShortcuts:shortcuts", + "setting:keyboardShortcuts:base-keymap", "setting:keyboardShortcuts:shortcut-chords", "setting:keyboardShortcuts:reset-defaults", "setting:terminal:memory-guardrail", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d12c6051256..6650b09905f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -559958,6 +559958,2484 @@ } } } + }, + "command.shortcutKeymap.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Base Keymap: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخطط المفاتيح الأساسي: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Basis-Tastaturbelegung: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mapa de teclas base: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition de base : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベースキーマップ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 키맵: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础键位方案:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基礎按鍵配置:%@" + } + } + } + }, + "dialog.shortcutKeymap.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keymap Not Changed" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تغيير مخطط المفاتيح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastaturbelegung nicht geändert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se cambió el mapa de teclas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition non modifiée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーマップは変更されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키맵이 변경되지 않음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未更改键位方案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未變更按鍵配置" + } + } + } + }, + "settings.shortcuts.baseKeymap": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Base Keymap" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخطط المفاتيح الأساسي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Basis-Tastaturbelegung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mapa de teclas base" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition de base" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベースキーマップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 키맵" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础键位方案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基礎按鍵配置" + } + } + } + }, + "settings.shortcuts.baseKeymap.conflictHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change or turn off the macOS shortcut in System Settings > Keyboard > Keyboard Shortcuts." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غيّر اختصار macOS أو أوقفه في إعدادات النظام > لوحة المفاتيح > اختصارات لوحة المفاتيح." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ändern oder deaktivieren Sie das macOS-Tastaturkürzel unter Systemeinstellungen > Tastatur > Tastaturkurzbefehle." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambia o desactiva el atajo de macOS en Ajustes del Sistema > Teclado > Atajos de teclado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifiez ou désactivez le raccourci macOS dans Réglages Système > Clavier > Raccourcis clavier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム設定 > キーボード > キーボードショートカットで macOS のショートカットを変更するかオフにしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 > 키보드 > 키보드 단축키에서 macOS 단축키를 변경하거나 끄세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在“系统设置”>“键盘”>“键盘快捷键”中更改或关闭该 macOS 快捷键。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請在「系統設定」>「鍵盤」>「鍵盤快速鍵」中更改或關閉該 macOS 快速鍵。" + } + } + } + }, + "settings.shortcuts.baseKeymap.custom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخصص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefiniert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Personalizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂" + } + } + } + }, + "settings.shortcuts.baseKeymap.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start from another terminal's shortcuts. Only the differences are written to cmux.json; choose cmux to remove them." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ من اختصارات طرفية أخرى. تُكتب الاختلافات فقط في cmux.json؛ اختر cmux لإزالتها." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit den Tastaturkürzeln eines anderen Terminals beginnen. Nur die Unterschiede werden in cmux.json geschrieben; wählen Sie cmux, um sie zu entfernen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Parte de los atajos de otro terminal. Solo se escriben las diferencias en cmux.json; elige cmux para eliminarlas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Partez des raccourcis d’un autre terminal. Seules les différences sont écrites dans cmux.json ; choisissez cmux pour les supprimer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のターミナルのショートカットから始めます。差分だけが cmux.json に書き込まれ、cmux を選ぶと削除されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 터미널의 단축키에서 시작합니다. 차이점만 cmux.json에 기록되며, cmux를 선택하면 제거됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以其他终端的快捷键为起点。只会将差异写入 cmux.json;选择 cmux 即可移除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以其他終端機的快速鍵為起點。只會將差異寫入 cmux.json;選擇 cmux 即可移除。" + } + } + } + }, + "shortcut.keymap.preset.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux (Default)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux (الافتراضي)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux (Standard)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux (predeterminado)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux (par défaut)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux(デフォルト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux (기본값)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux(默认)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux(預設)" + } + } + } + }, + "shortcut.keymap.preset.iterm2": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "iTerm2" + } + } + } + }, + "shortcut.keymap.preset.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Terminal.app" + } + } + } + }, + "shortcut.keymap.preset.tmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tmux-style (Ctrl-B Prefix)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نمط tmux (البادئة Ctrl-B)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "tmux-Stil (Ctrl-B-Präfix)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estilo tmux (prefijo Ctrl-B)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style tmux (préfixe Ctrl-B)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "tmux 風(Ctrl-B プレフィックス)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "tmux 스타일 (Ctrl-B 접두사)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "tmux 风格(Ctrl-B 前缀)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "tmux 風格(Ctrl-B 前綴)" + } + } + } + }, + "shortcut.keymap.summary.change": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@: %2$@ → %3$@" + } + } + } + }, + "shortcut.keymap.summary.conflict": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ is also a macOS shortcut (%2$@). macOS may handle it before cmux." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ هو أيضًا اختصار في macOS (%2$@). قد يعالجه macOS قبل cmux." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ ist auch ein macOS-Tastaturkürzel (%2$@). macOS verarbeitet es möglicherweise vor cmux." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ también es un atajo de macOS (%2$@). macOS puede procesarlo antes que cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ est aussi un raccourci macOS (%2$@). macOS peut le traiter avant cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ は macOS のショートカット(%2$@)でもあります。cmux より先に macOS が処理する場合があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@은(는) macOS 단축키(%2$@)이기도 합니다. macOS가 cmux보다 먼저 처리할 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 也是 macOS 快捷键(%2$@)。macOS 可能会先于 cmux 处理它。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ 也是 macOS 快速鍵(%2$@)。macOS 可能會先於 cmux 處理它。" + } + } + } + }, + "shortcut.keymap.summary.kept": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kept your own shortcut for %@." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الاحتفاظ باختصارك الخاص لـ %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ihr eigenes Tastaturkürzel für %@ wurde beibehalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se conservó tu propio atajo para %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre propre raccourci pour %@ a été conservé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に設定したショートカットはそのまま残しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 대한 사용자 단축키를 유지했습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保留你为“%@”设置的快捷键。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已保留您為「%@」設定的快速鍵。" + } + } + } + }, + "shortcut.keymap.summary.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No shortcuts changed." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تغيير أي اختصارات." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Tastaturkürzel geändert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se cambió ningún atajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun raccourci modifié." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更されたショートカットはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "변경된 단축키가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未更改任何快捷键。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未變更任何快速鍵。" + } + } + } + }, + "shortcut.macos.accessibilityShortcuts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Accessibility Shortcuts" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار اختصارات تسهيلات الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kurzbefehle für Bedienungshilfen anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar funciones rápidas de accesibilidad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les raccourcis d’accessibilité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセシビリティのショートカットを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "손쉬운 사용 단축키 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示辅助功能快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示輔助使用快速鍵" + } + } + } + }, + "shortcut.macos.appSwitcher": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch apps" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التبديل بين التطبيقات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischen Apps wechseln" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar de app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer d’une app à l’autre" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを切り替える" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 전환" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换 App" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換 App" + } + } + } + }, + "shortcut.macos.appSwitcherReverse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch apps backward" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "التبديل بين التطبيقات للخلف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rückwärts zwischen Apps wechseln" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar de app hacia atrás" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer d’une app à l’autre en sens inverse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを逆順に切り替える" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 역방향 전환" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "反向切换 App" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "反向切換 App" + } + } + } + }, + "shortcut.macos.applicationWindows": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show application windows" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار نوافذ التطبيق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Programmfenster anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar ventanas de la aplicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les fenêtres d’application" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリケーションウインドウを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "응용 프로그램 윈도우 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示应用程序窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示應用程式視窗" + } + } + } + }, + "shortcut.macos.characterViewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Emoji & Symbols" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار الرموز التعبيرية والرموز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Emoji & Symbole anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar emojis y símbolos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher Emoji et symboles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "絵文字と記号を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이모티콘 및 기호 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示表情与符号" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示表情符號與符號" + } + } + } + }, + "shortcut.macos.finderSearch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Finder search window" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار نافذة البحث في Finder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finder-Suchfenster anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar ventana de búsqueda del Finder" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la fenêtre de recherche du Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finder の検索ウインドウを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Finder 검색 윈도우 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示“访达”搜索窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 Finder 搜尋視窗" + } + } + } + }, + "shortcut.macos.focusDock": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move focus to the Dock" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل التركيز إلى Dock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus auf das Dock bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover el foco al Dock" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Placer le curseur dans le Dock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dock にフォーカスを移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock으로 포커스 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将焦点移到程序坞" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將焦點移至 Dock" + } + } + } + }, + "shortcut.macos.focusMenuBar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move focus to the menu bar" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل التركيز إلى شريط القائمة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus auf die Menüleiste bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover el foco a la barra de menús" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Placer le curseur dans la barre des menus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバーにフォーカスを移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴 막대로 포커스 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将焦点移到菜单栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將焦點移至選單列" + } + } + } + }, + "shortcut.macos.helpMenuSearch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Help menu" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار قائمة التعليمات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Menü „Hilfe“ anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar el menú Ayuda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le menu Aide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「ヘルプ」メニューを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도움말 메뉴 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示“帮助”菜单" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示「輔助說明」選單" + } + } + } + }, + "shortcut.macos.lockScreen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock Screen" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قفل الشاشة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirm sperren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bloquear pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Verrouiller l’écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面をロック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 잠금" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁定屏幕" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鎖定螢幕" + } + } + } + }, + "shortcut.macos.logOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Log Out" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تسجيل الخروج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abmelden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer la session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログアウト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登出" + } + } + } + }, + "shortcut.macos.lookUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Look up & data detectors" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث وكاشفات البيانات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nachschlagen & Datenerkennung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Consultar y detectores de datos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recherche et détecteurs de données" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "調べる/データ検出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾아보기 및 데이터 감지기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查询与数据检测器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查詢與資料偵測器" + } + } + } + }, + "shortcut.macos.missionControl": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Mission Control" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار Mission Control" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mission Control anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar Mission Control" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher Mission Control" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mission Control を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mission Control 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示调度中心" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示「指揮中心」" + } + } + } + }, + "shortcut.macos.nextInputSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select the next input source" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديد مصدر الإدخال التالي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Eingabequelle auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccionar la siguiente fuente de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionner la source de saisie suivante" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次の入力ソースを選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 입력 소스 선택" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择下一个输入法" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選取下一個輸入方式" + } + } + } + }, + "shortcut.macos.nextWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move focus to the next window" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل التركيز إلى النافذة التالية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus auf das nächste Fenster bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover el foco a la siguiente ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Placer le curseur sur la fenêtre suivante" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のウインドウにフォーカスを移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 윈도우로 포커스 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将焦点移到下一个窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將焦點移至下一個視窗" + } + } + } + }, + "shortcut.macos.previousInputSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select the previous input source" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديد مصدر الإدخال السابق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorherige Eingabequelle auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccionar la fuente de entrada anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionner la source de saisie précédente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前の入力ソースを選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 입력 소스 선택" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择上一个输入法" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選取上一個輸入方式" + } + } + } + }, + "shortcut.macos.previousWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move focus to the previous window" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل التركيز إلى النافذة السابقة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus auf das vorherige Fenster bewegen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover el foco a la ventana anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Placer le curseur sur la fenêtre précédente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のウインドウにフォーカスを移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 윈도우로 포커스 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将焦点移到上一个窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將焦點移至上一個視窗" + } + } + } + }, + "shortcut.macos.screenshotScreen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save picture of screen as a file" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ صورة الشاشة كملف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmfoto als Datei sichern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar imagen de la pantalla como archivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer une image de l’écran dans un fichier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面のピクチャをファイルとして保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 사진을 파일로 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将屏幕图片存储为文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將螢幕圖片儲存為檔案" + } + } + } + }, + "shortcut.macos.screenshotScreenToClipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy picture of screen to the clipboard" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ صورة الشاشة إلى الحافظة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmfoto in die Zwischenablage kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar imagen de la pantalla en el portapapeles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier une image de l’écran dans le presse-papiers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面のピクチャをクリップボードにコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 사진을 클립보드로 복사" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将屏幕图片拷贝到剪贴板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將螢幕圖片拷貝到剪貼板" + } + } + } + }, + "shortcut.macos.screenshotSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save picture of selected area as a file" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ صورة المنطقة المحددة كملف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Foto des ausgewählten Bereichs als Datei sichern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar imagen del área seleccionada como archivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer une image de la zone sélectionnée dans un fichier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択部分のピクチャをファイルとして保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 영역 사진을 파일로 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将所选区域的图片存储为文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將所選區域的圖片儲存為檔案" + } + } + } + }, + "shortcut.macos.screenshotSelectionToClipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy picture of selected area to the clipboard" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ صورة المنطقة المحددة إلى الحافظة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Foto des ausgewählten Bereichs in die Zwischenablage kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar imagen del área seleccionada en el portapapeles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier une image de la zone sélectionnée dans le presse-papiers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択部分のピクチャをクリップボードにコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 영역 사진을 클립보드로 복사" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将所选区域的图片拷贝到剪贴板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將所選區域的圖片拷貝到剪貼板" + } + } + } + }, + "shortcut.macos.screenshotToolbar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Screenshot and recording options" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خيارات لقطة الشاشة والتسجيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmfoto- und Aufnahmeoptionen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opciones de captura de pantalla y grabación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Options de capture d’écran et d’enregistrement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スクリーンショットと収録のオプション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스크린샷 및 기록 옵션" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "截屏和录制选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "截圖和錄製選項" + } + } + } + }, + "shortcut.macos.spaceLeft": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move left a space" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال مساحة واحدة إلى اليسار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einen Space nach links" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir un espacio a la izquierda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller un espace à gauche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左の操作スペースへ移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 스페이스로 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向左移动一个空间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向左移動一個空間" + } + } + } + }, + "shortcut.macos.spaceRight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move right a space" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتقال مساحة واحدة إلى اليمين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einen Space nach rechts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ir un espacio a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aller un espace à droite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右の操作スペースへ移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 스페이스로 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右移动一个空间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右移動一個空間" + } + } + } + }, + "shortcut.macos.spotlight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Spotlight search" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار بحث Spotlight" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spotlight-Suche anzeigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar búsqueda de Spotlight" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la recherche Spotlight" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Spotlight 検索を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Spotlight 검색 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示“聚焦”搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 Spotlight 搜尋" + } + } + } + }, + "shortcut.macos.toggleDockHiding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn Dock hiding on or off" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل أو إيقاف إخفاء Dock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Ausblenden des Docks ein- oder ausschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar o desactivar la ocultación del Dock" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer ou désactiver le masquage du Dock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dock を自動的に表示/非表示のオン/オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock 가리기 켜기 또는 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开或关闭程序坞隐藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟或關閉 Dock 隱藏" + } + } + } + }, + "shortcut.macos.voiceOver": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn VoiceOver on or off" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل VoiceOver أو إيقافه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "VoiceOver ein- oder ausschalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar o desactivar VoiceOver" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer ou désactiver VoiceOver" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VoiceOver のオン/オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VoiceOver 켜기 또는 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开或关闭旁白" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟或關閉「旁白」" + } + } + } + }, + "settings.search.alias.setting.keyboardShortcuts.base-keymap": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "base keymap preset iterm iterm2 terminal app tmux prefix keybindings coming from another terminal" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخطط المفاتيح الأساسي إعداد مسبق iterm iterm2 terminal app tmux بادئة اختصارات طرفية أخرى" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Basis Tastaturbelegung Vorlage iterm iterm2 terminal app tmux Präfix Tastenbelegung anderes Terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "mapa de teclas base preajuste iterm iterm2 terminal app tmux prefijo atajos otro terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "disposition de base préréglage iterm iterm2 terminal app tmux préfixe raccourcis autre terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベースキーマップ プリセット iterm iterm2 terminal app tmux プレフィックス ショートカット 他のターミナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 키맵 프리셋 iterm iterm2 terminal app tmux 접두사 단축키 다른 터미널" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础键位方案 预设 iterm iterm2 terminal app tmux 前缀 快捷键 其他终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基礎按鍵配置 預設組合 iterm iterm2 terminal app tmux 前綴 快速鍵 其他終端機" + } + } + } } }, "version": "1.0" diff --git a/Sources/ContentView+KeymapPresetCommands.swift b/Sources/ContentView+KeymapPresetCommands.swift new file mode 100644 index 00000000000..d2b06268e87 --- /dev/null +++ b/Sources/ContentView+KeymapPresetCommands.swift @@ -0,0 +1,66 @@ +import AppKit +import CmuxCommandPalette +import CmuxSettings +import CmuxSettingsUI + +extension ContentView { + private static var keymapPresetTitleFormat: String { + String(localized: "command.shortcutKeymap.title", defaultValue: "Base Keymap: %@") + } + + static func keymapPresetCommandID(_ preset: ShortcutKeymapPreset) -> String { + "palette.shortcutKeymap.\(preset.rawValue)" + } + + func appendKeymapPresetCommandContributions(to contributions: inout [CommandPaletteCommandContribution]) { + let format = Self.keymapPresetTitleFormat + let subtitle = String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts") + for preset in ShortcutKeymapPreset.allCases { + contributions.append( + CommandPaletteCommandContribution( + commandId: Self.keymapPresetCommandID(preset), + title: { _ in String.localizedStringWithFormat(format, preset.displayName) }, + subtitle: { _ in subtitle }, + keywords: ["keymap", "base", "keybindings", "shortcuts", "preset", "iterm", "iterm2", "terminal", "tmux", "default"] + ) + ) + } + } + + func registerKeymapPresetCommandHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + for preset in ShortcutKeymapPreset.allCases { + registry.register(commandId: Self.keymapPresetCommandID(preset)) { + Task { @MainActor in await Self.applyKeymapPreset(preset) } + } + } + } + + /// Applies `preset` through the same plan and store path as the Settings + /// picker, then reports what changed. + @MainActor + private static func applyKeymapPreset(_ preset: ShortcutKeymapPreset) async { + guard let runtime = AppDelegate.shared?.settingsRuntime else { + NSSound.beep() + return + } + let bindingsKey = runtime.catalog.shortcuts.bindingSnapshot + let plan = preset.plan( + from: await runtime.jsonStore.value(for: bindingsKey), + defaultShortcutResolver: runtime.shortcutDefaultResolver + ) + let alert = NSAlert() + do { + try await runtime.jsonStore.applyShortcutKeymap(plan, bindingsID: bindingsKey.id) + runtime.hostActions.notifyShortcutSettingsDidChange() + alert.messageText = String.localizedStringWithFormat(keymapPresetTitleFormat, preset.displayName) + alert.informativeText = ShortcutKeymapPlanText.lines(for: plan).joined(separator: "\n") + alert.alertStyle = plan.systemConflicts.isEmpty ? .informational : .warning + } catch { + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.shortcutKeymap.failed.title", defaultValue: "Keymap Not Changed") + alert.informativeText = error.localizedDescription + } + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + _ = alert.runCmuxModal() + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e75145d7478..62c2f8121c0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7943,6 +7943,7 @@ struct ContentView: View { panelSubtitle: panelSubtitle ) appendSavedLayoutCommandContributions(to: &contributions, workspaceSubtitle: workspaceSubtitle) + appendKeymapPresetCommandContributions(to: &contributions) contributions.append( CommandPaletteCommandContribution( @@ -8872,6 +8873,7 @@ struct ContentView: View { registerCloudCommandHandlers(®istry) registerComputerUseCommandPaletteHandlers(®istry) registerSavedLayoutCommandHandlers(®istry) + registerKeymapPresetCommandHandlers(®istry) registry.register(commandId: "palette.showNotifications") { AppDelegate.shared?.toggleNotificationsPopover(animated: false) } diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 0db982b62eb..954d9f8d3d5 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -198,6 +198,7 @@ enum SettingsSearchAliasIndex { "browser:history": localized("settings.search.alias.setting.browser.history", defaultValue: "clear browser history visited pages suggestions omnibar"), "globalHotkey:enable-hotkey": localized("settings.search.alias.setting.globalHotkey.enable-hotkey", defaultValue: "global hotkey enable system wide show hide all windows"), "globalHotkey:shortcut": localized("settings.search.alias.setting.globalHotkey.shortcut", defaultValue: "global hotkey shortcut recorder key command option control"), + "keyboardShortcuts:base-keymap": localized("settings.search.alias.setting.keyboardShortcuts.base-keymap", defaultValue: "base keymap preset iterm iterm2 terminal app tmux prefix keybindings coming from another terminal"), "keyboardShortcuts:shortcut-chords": localized("settings.search.alias.setting.keyboardShortcuts.shortcut-chords", defaultValue: "tmux prefix ctrl-b control-b multi key sequence chord cmux json"), "keyboardShortcuts:reset-defaults": localized("settings.search.alias.setting.keyboardShortcuts.reset-defaults", defaultValue: "reset restore default defaults built in builtin shortcuts hotkeys keybindings commands"), "keyboardShortcuts:shortcuts": localized("settings.search.alias.setting.keyboardShortcuts.shortcuts", defaultValue: "hotkeys keybindings key bindings commands keyboard accelerators shortcuts cmux json"), diff --git a/Sources/SettingsSearchIndex.swift b/Sources/SettingsSearchIndex.swift index 7b9caec11b7..12f9d166a8e 100644 --- a/Sources/SettingsSearchIndex.swift +++ b/Sources/SettingsSearchIndex.swift @@ -186,6 +186,7 @@ enum SettingsSearchIndex { setting(.browser, "history", String(localized: "settings.browser.history", defaultValue: "Browsing History"), "clear visited suggestions"), setting(.globalHotkey, "enable-hotkey", String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), "global shortcut show hide windows"), setting(.globalHotkey, "shortcut", String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey"), "keyboard recorder command option control"), + setting(.keyboardShortcuts, "base-keymap", String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), "preset iterm2 terminal tmux keybindings"), setting(.keyboardShortcuts, "shortcut-chords", String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), "tmux multi step keybindings"), setting(.keyboardShortcuts, "pane-resize-step", String(localized: "settings.shortcuts.paneResizeStep", defaultValue: "Pane Resize Step"), "resize pane split step pixels keyboard repeat"), setting(.keyboardShortcuts, "reset-defaults", String(localized: "settings.shortcuts.resetDefaults", defaultValue: "Reset Default Shortcuts"), "restore built in builtin defaults keybindings hotkeys chords commands"), diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fedd7fa483b..cb08454e76d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1536,6 +1536,7 @@ C0A714000000000000000002 /* ContentView+ComputerUseCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A714000000000000000001 /* ContentView+ComputerUseCommandPalette.swift */; }; F02D5E1E7F70FB44242D747A /* ContentView+CurrentWorkCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C30683E64A47DAACCBF1952 /* ContentView+CurrentWorkCommandPalette.swift */; }; C0DEF0A90000000000000001 /* ContentView+ForkAgentConversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */; }; + 4B3E7A9C0D1F2E5A6B7C8D01 /* ContentView+KeymapPresetCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3E7A9C0D1F2E5A6B7C8D02 /* ContentView+KeymapPresetCommands.swift */; }; D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */; }; 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */; }; C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */; }; @@ -5526,6 +5527,7 @@ C0A714000000000000000001 /* ContentView+ComputerUseCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ComputerUseCommandPalette.swift"; sourceTree = ""; }; 6C30683E64A47DAACCBF1952 /* ContentView+CurrentWorkCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CurrentWorkCommandPalette.swift"; sourceTree = ""; }; C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ForkAgentConversation.swift"; sourceTree = ""; }; + 4B3E7A9C0D1F2E5A6B7C8D02 /* ContentView+KeymapPresetCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+KeymapPresetCommands.swift"; sourceTree = ""; }; D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+PaneResizeCommands.swift"; sourceTree = ""; }; C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ProCommandPalette.swift"; sourceTree = ""; }; @@ -8921,6 +8923,7 @@ C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */, + 4B3E7A9C0D1F2E5A6B7C8D02 /* ContentView+KeymapPresetCommands.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, @@ -13586,6 +13589,7 @@ C0A714000000000000000002 /* ContentView+ComputerUseCommandPalette.swift in Sources */, F02D5E1E7F70FB44242D747A /* ContentView+CurrentWorkCommandPalette.swift in Sources */, C0DEF0A90000000000000001 /* ContentView+ForkAgentConversation.swift in Sources */, + 4B3E7A9C0D1F2E5A6B7C8D01 /* ContentView+KeymapPresetCommands.swift in Sources */, D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */, 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */, C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */, diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index af95f4b069f..db25c09e818 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -2468,6 +2468,18 @@ "de": "Established UI or technical term with the same spelling in this locale; a translated entry is still required." } }, + "shortcut.keymap.preset.iterm2": { + "class": "brand", + "source": "iTerm2" + }, + "shortcut.keymap.preset.terminal": { + "class": "brand", + "source": "Terminal.app" + }, + "shortcut.keymap.summary.change": { + "class": "format", + "source": "%1$@: %2$@ → %3$@" + }, "shortcut.recorder.pendingChord": { "class": "format", "source": "%@ …" diff --git a/skills/cmux-keyboard-shortcuts/SKILL.md b/skills/cmux-keyboard-shortcuts/SKILL.md index 8a1dbd4cc2b..b14f66bfae4 100644 --- a/skills/cmux-keyboard-shortcuts/SKILL.md +++ b/skills/cmux-keyboard-shortcuts/SKILL.md @@ -81,6 +81,8 @@ fi ## Preset templates +Settings > Keyboard Shortcuts > Base Keymap (also "Base Keymap: ..." in the Command Palette) ships cmux, iTerm2, Terminal.app, and tmux-style presets. It writes only the overrides that differ from cmux defaults, keeps bindings the user set by hand, and lists macOS shortcut conflicts. Point users there for those styles; use the templates below for the others. + Apply action by action, never by overwriting the whole `shortcuts.bindings` object. ### Tmux Prefix From a999923432b8e879a826522bdd03cbac46ef4a82 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 27 Sep 2026 09:50:59 -0400 Subject: [PATCH 2/3] Preview base keymaps before applying and respect legacy bindings - The Base Keymap picker now previews the changes (with macOS conflicts and, for iTerm2, a note that Cmd-1...9 moves from workspaces to tabs) and writes only after Apply Keymap. - The palette commands open Settings > Keyboard Shortcuts with that preview instead of applying and showing a modal alert. - plan(from:legacyBindings:) treats UserDefaults shortcuts as set by hand, reports them as the "before" value, and reveals them again when a preset override is removed. - ShortcutKeymapPlanText becomes ShortcutKeymapPlan.summaryLines. - Document that preset ownership is inferred from values. Co-Authored-By: Claude Opus 5.5 --- .../Values/ShortcutKeymapPlan.swift | 37 +++- .../ShortcutKeymapPresetTests.swift | 39 ++++ .../Environment/SettingsRuntime.swift | 4 + .../ShortcutKeymapProposalInbox.swift | 15 ++ .../Scene/SettingsWindowScene+Sections.swift | 3 +- .../Sections/KeyboardShortcutsSection.swift | 8 +- .../Sections/ShortcutKeymapPresetRow.swift | 115 ++++++++---- .../Sections/ShortcutKeymapPresetText.swift | 42 +++-- Resources/Localizable.xcstrings | 177 ++++++++++++------ .../ContentView+KeymapPresetCommands.swift | 48 ++--- skills/cmux-keyboard-shortcuts/SKILL.md | 2 +- 11 files changed, 332 insertions(+), 158 deletions(-) create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ShortcutKeymapProposalInbox.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift index d98c6c7cfc4..34b8e8b1588 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutKeymapPlan.swift @@ -18,8 +18,13 @@ public struct ShortcutKeymapChange: Sendable, Equatable { /// The `shortcuts.bindings` edits that switch the file to a keymap preset. /// /// A preset only replaces overrides that are absent or that another preset -/// wrote. A binding the user set by hand stays and is listed in ``kept``, so -/// switching presets never discards the user's own edits. +/// wrote. A binding the user set by hand, in `cmux.json` or as a legacy +/// UserDefaults shortcut, stays and is listed in ``kept``. +/// +/// Ownership is inferred from values, not recorded: nothing marks which +/// preset wrote a binding. A binding typed by hand that equals some preset's +/// value for that action therefore counts as preset-owned, and switching to +/// another preset (including cmux) replaces or removes it. public struct ShortcutKeymapPlan: Sendable, Equatable { /// The preset this plan switches to. public let preset: ShortcutKeymapPreset @@ -42,11 +47,15 @@ extension ShortcutKeymapPreset { /// /// - Parameters: /// - snapshot: The current `shortcuts.bindings` contents of `cmux.json`. + /// - legacyBindings: Shortcuts saved in UserDefaults by older Settings + /// builds, keyed by action id. They apply when `cmux.json` doesn't + /// manage the action and count as set by hand. /// - defaultShortcutResolver: The host's factory defaults, used to show /// effective before/after values and to skip writes that equal a default. /// - Returns: The overrides to write or remove, plus the user bindings kept. public func plan( from snapshot: ShortcutBindingsSnapshot, + legacyBindings: [String: StoredShortcut] = [:], defaultShortcutResolver: ShortcutDefaultResolver = .builtIn ) -> ShortcutKeymapPlan { let targetOverrides = overrides @@ -58,16 +67,20 @@ extension ShortcutKeymapPreset { let managed = current != nil || snapshot.managedActionIDs.contains(action.rawValue) let defaultShortcut = (action.defaultShortcut(using: defaultShortcutResolver) ?? .unbound) .canonicalized() - let before = managed ? (current ?? defaultShortcut) : defaultShortcut + // A file override hides the legacy value; removing it reveals it again. + let legacyValue = legacyBindings[action.rawValue]?.canonicalized() + let legacy = managed ? nil : legacyValue + let before = managed ? (current ?? defaultShortcut) : (legacy ?? defaultShortcut) let ownedByPreset = current.map { Self.isPresetValue($0, for: action) } ?? false + let setByHand = (managed && !ownedByPreset) || legacy != nil if let binding = targetOverrides[action], let target = binding.shortcut { guard current != target else { continue } - guard !managed || ownedByPreset else { + if !managed && target == before { continue } + guard !setByHand else { kept.append(action) continue } - if !managed && target == defaultShortcut { continue } changes.append(ShortcutKeymapChange( action: action, before: before, @@ -76,12 +89,13 @@ extension ShortcutKeymapPreset { systemConflicts: MacOSSystemShortcut.conflicts(with: target, for: action) )) } else if ownedByPreset { + let after = legacyValue ?? defaultShortcut changes.append(ShortcutKeymapChange( action: action, before: before, - after: defaultShortcut, + after: after, write: nil, - systemConflicts: MacOSSystemShortcut.conflicts(with: defaultShortcut, for: action) + systemConflicts: MacOSSystemShortcut.conflicts(with: after, for: action) )) } } @@ -97,6 +111,7 @@ extension ShortcutKeymapPreset { /// actions, or to a preset's own actions, don't hide the preset. public static func active( in snapshot: ShortcutBindingsSnapshot, + legacyBindings: [String: StoredShortcut] = [:], defaultShortcutResolver: ShortcutDefaultResolver = .builtIn ) -> ShortcutKeymapPreset? { for preset in allCases where preset != .cmux { @@ -104,12 +119,16 @@ extension ShortcutKeymapPreset { snapshot.bindings[action.rawValue]?.canonicalized() == binding.shortcut } if applied, - preset.plan(from: snapshot, defaultShortcutResolver: defaultShortcutResolver).isEmpty { + preset.plan( + from: snapshot, + legacyBindings: legacyBindings, + defaultShortcutResolver: defaultShortcutResolver + ).isEmpty { return preset } } return ShortcutKeymapPreset.cmux - .plan(from: snapshot, defaultShortcutResolver: defaultShortcutResolver) + .plan(from: snapshot, legacyBindings: legacyBindings, defaultShortcutResolver: defaultShortcutResolver) .isEmpty ? .cmux : nil } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift index f7f0c9dd6a4..c16961cf6c7 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutKeymapPresetTests.swift @@ -80,6 +80,45 @@ struct ShortcutKeymapPresetTests { #expect(toCmux.changes.contains { $0.action == .resizePaneLeft }) } + @Test func legacyUserDefaultsBindingsCountAsSetByHand() { + let legacyCopyMode = StoredShortcut(first: ShortcutStroke(key: "x", command: true, shift: true)) + let legacy = [ + ShortcutAction.toggleTerminalCopyMode.rawValue: legacyCopyMode, + ShortcutAction.resizePaneLeft.rawValue: ShortcutKeymapBinding.stroke("cmd+ctrl+left").shortcut!, + ] + + let plan = ShortcutKeymapPreset.iTerm2.plan(from: empty, legacyBindings: legacy) + + #expect(plan.kept == [.toggleTerminalCopyMode]) + #expect(!plan.changes.contains { $0.action == .toggleTerminalCopyMode }) + // Already the preset's value, so there is nothing to write or keep. + #expect(!plan.changes.contains { $0.action == .resizePaneLeft }) + #expect(ShortcutKeymapPreset.active(in: snapshot(applied(.iTerm2)), legacyBindings: legacy) == .iTerm2) + } + + @Test func removingAPresetOverrideRevealsTheLegacyBinding() throws { + let legacyCopyMode = StoredShortcut(first: ShortcutStroke(key: "x", command: true, shift: true)) + + let plan = ShortcutKeymapPreset.cmux.plan( + from: snapshot(applied(.iTerm2)), + legacyBindings: [ShortcutAction.toggleTerminalCopyMode.rawValue: legacyCopyMode] + ) + + let copyMode = try #require(plan.changes.first { $0.action == .toggleTerminalCopyMode }) + #expect(copyMode.write == nil) + #expect(copyMode.after == legacyCopyMode) + } + + @Test func handTypedPresetValueIsTreatedAsPresetOwned() { + // Ownership is inferred from values, so a hand-typed binding equal to + // a preset's value is removed when switching back to cmux. + let typed = snapshot([.toggleTerminalCopyMode: ShortcutKeymapBinding.stroke("cmd+shift+c").shortcut!]) + + let plan = ShortcutKeymapPreset.cmux.plan(from: typed) + + #expect(plan.changes.map(\.action) == [.toggleTerminalCopyMode]) + } + @Test func malformedManagedBindingCountsAsACustomization() { let managed = ShortcutBindingsSnapshot(bindings: [:], managedActionIDs: ["toggleTerminalCopyMode"]) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift index 29a90c54e0e..6a8b90f5316 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift @@ -39,6 +39,9 @@ public final class SettingsRuntime: @unchecked Sendable { public let hostActions: SettingsHostActions /// Host-scoped factory-default resolver for dynamic shortcut actions. public let shortcutDefaultResolver: ShortcutDefaultResolver + /// Base keymap proposals from outside Settings, previewed by the + /// Keyboard Shortcuts section before anything is written. + public let keymapProposals: ShortcutKeymapProposalInbox /// Creates the settings runtime bundle injected into the settings UI. /// @@ -75,6 +78,7 @@ public final class SettingsRuntime: @unchecked Sendable { self.accountFlow = accountFlow self.hostActions = hostActions self.shortcutDefaultResolver = shortcutDefaultResolver + self.keymapProposals = ShortcutKeymapProposalInbox() } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ShortcutKeymapProposalInbox.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ShortcutKeymapProposalInbox.swift new file mode 100644 index 00000000000..337bfef9482 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ShortcutKeymapProposalInbox.swift @@ -0,0 +1,15 @@ +import CmuxSettings +import Observation + +/// Hands a base keymap chosen outside Settings (the Command Palette) to the +/// Keyboard Shortcuts section, which previews it and applies it only when the +/// user confirms. +@MainActor +@Observable +public final class ShortcutKeymapProposalInbox { + /// The preset waiting for a preview, or `nil` when nothing is pending. + public var preset: ShortcutKeymapPreset? + + /// Creates an empty inbox. + public init() {} +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift index ae6b0877d79..04a30a4cfca 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift @@ -138,7 +138,8 @@ extension SettingsWindowRoot { catalog: catalog, errorLog: runtime.errorLog, hostActions: hostActions, - defaultShortcutResolver: runtime.shortcutDefaultResolver + defaultShortcutResolver: runtime.shortcutDefaultResolver, + keymapProposals: runtime.keymapProposals ) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index 08a4a61f272..1838ef07a54 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -9,6 +9,7 @@ import SwiftUI @MainActor public struct KeyboardShortcutsSection: View { private let hostActions: SettingsHostActions + private let keymapProposals: ShortcutKeymapProposalInbox? @State private var model: ShortcutListModel @State private var paneResizeStep: DefaultsValueModel @@ -22,15 +23,18 @@ public struct KeyboardShortcutsSection: View { /// - errorLog: The error sink for failed JSON writes. /// - hostActions: Host callbacks for opening the external configuration editor. /// - defaultShortcutResolver: Host-scoped factory defaults for dynamic actions. + /// - keymapProposals: Base keymap choices from outside Settings to preview here. public init( jsonStore: JSONConfigStore, userDefaultsStore: UserDefaultsSettingsStore? = nil, catalog: SettingCatalog, errorLog: SettingsErrorLog, hostActions: SettingsHostActions, - defaultShortcutResolver: ShortcutDefaultResolver = .builtIn + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn, + keymapProposals: ShortcutKeymapProposalInbox? = nil ) { self.hostActions = hostActions + self.keymapProposals = keymapProposals _model = State(initialValue: ShortcutListModel( jsonStore: jsonStore, userDefaultsStore: userDefaultsStore, @@ -53,7 +57,7 @@ public struct KeyboardShortcutsSection: View { SettingsSectionHeader(String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), section: .keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { - ShortcutKeymapPresetRow(model: model) + ShortcutKeymapPresetRow(model: model, proposals: keymapProposals) SettingsCardDivider() chordsRow SettingsCardDivider() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift index 947d115151d..daf8537ca9c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift @@ -2,12 +2,14 @@ import CmuxFoundation import CmuxSettings import SwiftUI -/// The **Base Keymap** picker: switches `shortcuts.bindings` to a preset and -/// lists what changed, including shortcuts macOS may take first. +/// The **Base Keymap** picker. Choosing a preset (here or from the Command +/// Palette) previews what would change, including shortcuts macOS may take +/// first, and writes `shortcuts.bindings` only after the user confirms. @MainActor struct ShortcutKeymapPresetRow: View { let model: ShortcutListModel - @State private var lastPlan: ShortcutKeymapPlan? + let proposals: ShortcutKeymapProposalInbox? + @State private var proposedPlan: ShortcutKeymapPlan? @State private var isApplying = false private var snapshot: ShortcutBindingsSnapshot { @@ -18,7 +20,11 @@ struct ShortcutKeymapPresetRow: View { } private var activePreset: ShortcutKeymapPreset? { - ShortcutKeymapPreset.active(in: snapshot, defaultShortcutResolver: model.defaultShortcutResolver) + ShortcutKeymapPreset.active( + in: snapshot, + legacyBindings: model.legacyBindings, + defaultShortcutResolver: model.defaultShortcutResolver + ) } var body: some View { @@ -36,16 +42,16 @@ struct ShortcutKeymapPresetRow: View { Picker( String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), selection: Binding( - get: { activePreset }, + get: { proposedPlan?.preset ?? activePreset }, set: { preset in - if let preset { apply(preset) } + if let preset { propose(preset) } } ) ) { ForEach(ShortcutKeymapPreset.allCases, id: \.self) { preset in Text(preset.displayName).tag(Optional(preset)) } - if activePreset == nil { + if proposedPlan == nil, activePreset == nil { Text(String(localized: "settings.shortcuts.baseKeymap.custom", defaultValue: "Custom")) .tag(ShortcutKeymapPreset?.none) } @@ -55,43 +61,86 @@ struct ShortcutKeymapPresetRow: View { .disabled(isApplying) .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapPicker") } - if let lastPlan { - VStack(alignment: .leading, spacing: 3) { - ForEach(Array(ShortcutKeymapPlanText.lines(for: lastPlan).enumerated()), id: \.offset) { _, line in - Text(line) - .cmuxFont(.caption) - .foregroundColor(.secondary) - .textSelection(.enabled) - } - if !lastPlan.systemConflicts.isEmpty { - Label( - String( - localized: "settings.shortcuts.baseKeymap.conflictHint", - defaultValue: "Change or turn off the macOS shortcut in System Settings > Keyboard > Keyboard Shortcuts." - ), - systemImage: "exclamationmark.triangle" - ) - .cmuxFont(.caption) - .foregroundColor(.orange) - } + if let proposedPlan { + preview(proposedPlan) + } + } + .onAppear { takeProposal() } + .onChange(of: proposals?.preset) { _, _ in takeProposal() } + } + + @ViewBuilder + private func preview(_ plan: ShortcutKeymapPlan) -> some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(plan.summaryLines.enumerated()), id: \.offset) { _, line in + Text(line) + .cmuxFont(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + if !plan.systemConflicts.isEmpty { + Label( + String( + localized: "settings.shortcuts.baseKeymap.conflictHint", + defaultValue: "Change or turn off the macOS shortcut in System Settings > Keyboard > Keyboard Shortcuts." + ), + systemImage: "exclamationmark.triangle" + ) + .cmuxFont(.caption) + .foregroundColor(.orange) + } + HStack(spacing: 8) { + Spacer() + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { + proposedPlan = nil } - .padding(.horizontal, 14) - .padding(.bottom, 9) - .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapSummary") + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapCancel") + Button(String(localized: "settings.shortcuts.baseKeymap.apply", defaultValue: "Apply Keymap")) { + apply(plan) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(plan.isEmpty || isApplying) + .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapApply") } + .padding(.top, 4) + } + .padding(.horizontal, 14) + .padding(.bottom, 9) + .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapPreview") + } + + private func propose(_ preset: ShortcutKeymapPreset) { + guard preset != activePreset else { + proposedPlan = nil + return } + proposedPlan = preset.plan( + from: snapshot, + legacyBindings: model.legacyBindings, + defaultShortcutResolver: model.defaultShortcutResolver + ) + } + + private func takeProposal() { + guard let proposals, let preset = proposals.preset else { return } + proposals.preset = nil + proposedPlan = preset.plan( + from: snapshot, + legacyBindings: model.legacyBindings, + defaultShortcutResolver: model.defaultShortcutResolver + ) } - private func apply(_ preset: ShortcutKeymapPreset) { - let plan = preset.plan(from: snapshot, defaultShortcutResolver: model.defaultShortcutResolver) - lastPlan = plan - guard !plan.isEmpty else { return } + private func apply(_ plan: ShortcutKeymapPlan) { isApplying = true Task { defer { isApplying = false } do { try await model.jsonStore.applyShortcutKeymap(plan, bindingsID: model.catalog.shortcuts.bindings.id) model.onShortcutsChanged() + proposedPlan = nil } catch { model.errorLog.record(error, keyID: model.catalog.shortcuts.bindings.id) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift index 4b5645c7f57..3fe7efeacce 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift @@ -79,13 +79,12 @@ extension MacOSSystemShortcut { } } -/// Plain-text lines describing a keymap preset switch, shared by the Settings -/// row and the Command Palette confirmation. -public enum ShortcutKeymapPlanText { - /// One line per changed shortcut, then one per macOS conflict, then one per - /// user binding the preset kept. - public static func lines(for plan: ShortcutKeymapPlan) -> [String] { - guard !plan.isEmpty || !plan.kept.isEmpty else { +extension ShortcutKeymapPlan { + /// Plain-text preview of the switch: a note for presets that move a + /// familiar shortcut family, one line per changed shortcut, one per macOS + /// conflict, then one per binding set by hand that the preset keeps. + public var summaryLines: [String] { + guard !isEmpty || !kept.isEmpty else { return [String(localized: "shortcut.keymap.summary.none", defaultValue: "No shortcuts changed.")] } let changeFormat = String(localized: "shortcut.keymap.summary.change", defaultValue: "%1$@: %2$@ → %3$@") @@ -97,30 +96,37 @@ public enum ShortcutKeymapPlanText { localized: "shortcut.keymap.summary.kept", defaultValue: "Kept your own shortcut for %@." ) - var lines = plan.changes.map { change in + var lines: [String] = [] + if preset == .iTerm2, changes.contains(where: { $0.action == .selectSurfaceByNumber }) { + lines.append(String( + localized: "shortcut.keymap.summary.iterm2Numbers", + defaultValue: "⌘1…9 will select tabs in the focused pane instead of workspaces. Workspaces move to ⌥⌘1…9." + )) + } + lines += changes.map { change in String.localizedStringWithFormat( changeFormat, change.action.displayName, - display(change.before, for: change.action), - display(change.after, for: change.action) + keymapDisplayString(change.before, for: change.action), + keymapDisplayString(change.after, for: change.action) ) } - for change in plan.systemConflicts { + for change in systemConflicts { let names = change.systemConflicts.map(\.displayName).joined(separator: ", ") lines.append(String.localizedStringWithFormat( conflictFormat, - display(change.after, for: change.action), + keymapDisplayString(change.after, for: change.action), names )) } - lines += plan.kept.map { String.localizedStringWithFormat(keptFormat, $0.displayName) } + lines += kept.map { String.localizedStringWithFormat(keptFormat, $0.displayName) } return lines } +} - private static func display(_ shortcut: StoredShortcut, for action: ShortcutAction) -> String { - guard !shortcut.isUnbound else { - return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") - } - return shortcutDisplayString(shortcut, numbered: action.usesNumberedDigitMatching) +private func keymapDisplayString(_ shortcut: StoredShortcut, for action: ShortcutAction) -> String { + guard !shortcut.isUnbound else { + return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") } + return shortcutDisplayString(shortcut, numbered: action.usesNumberedDigitMatching) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 6650b09905f..a0cbb598563 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -560018,65 +560018,6 @@ } } }, - "dialog.shortcutKeymap.failed.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keymap Not Changed" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لم يتم تغيير مخطط المفاتيح" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Tastaturbelegung nicht geändert" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se cambió el mapa de teclas" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Disposition non modifiée" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キーマップは変更されませんでした" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "키맵이 변경되지 않음" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未更改键位方案" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "未變更按鍵配置" - } - } - } - }, "settings.shortcuts.baseKeymap": { "extractionState": "manual", "localizations": { @@ -562436,6 +562377,124 @@ } } } + }, + "settings.shortcuts.baseKeymap.apply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply Keymap" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق مخطط المفاتيح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastaturbelegung anwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicar mapa de teclas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appliquer la disposition" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーマップを適用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키맵 적용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用键位方案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用按鍵配置" + } + } + } + }, + "shortcut.keymap.summary.iterm2Numbers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 will select tabs in the focused pane instead of workspaces. Workspaces move to ⌥⌘1…9." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيحدد ⌘1…9 علامات التبويب في الجزء المحدد بدلًا من مساحات العمل. تنتقل مساحات العمل إلى ⌥⌘1…9." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 wählt dann Tabs im fokussierten Bereich statt Arbeitsbereiche aus. Arbeitsbereiche wechseln zu ⌥⌘1…9." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 seleccionará pestañas del panel enfocado en lugar de espacios de trabajo. Los espacios de trabajo pasan a ⌥⌘1…9." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 sélectionnera les onglets du panneau actif au lieu des espaces de travail. Les espaces de travail passent à ⌥⌘1…9." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 はワークスペースではなく、フォーカス中のペインのタブを選択するようになります。ワークスペースは ⌥⌘1…9 に移ります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9는 작업 공간 대신 포커스된 패널의 탭을 선택합니다. 작업 공간은 ⌥⌘1…9로 이동합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 将选择当前窗格中的标签页,而不是工作区。工作区改用 ⌥⌘1…9。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "⌘1…9 將選取目前窗格中的分頁,而不是工作區。工作區改用 ⌥⌘1…9。" + } + } + } } }, "version": "1.0" diff --git a/Sources/ContentView+KeymapPresetCommands.swift b/Sources/ContentView+KeymapPresetCommands.swift index d2b06268e87..d097af8fc26 100644 --- a/Sources/ContentView+KeymapPresetCommands.swift +++ b/Sources/ContentView+KeymapPresetCommands.swift @@ -4,16 +4,12 @@ import CmuxSettings import CmuxSettingsUI extension ContentView { - private static var keymapPresetTitleFormat: String { - String(localized: "command.shortcutKeymap.title", defaultValue: "Base Keymap: %@") - } - static func keymapPresetCommandID(_ preset: ShortcutKeymapPreset) -> String { "palette.shortcutKeymap.\(preset.rawValue)" } func appendKeymapPresetCommandContributions(to contributions: inout [CommandPaletteCommandContribution]) { - let format = Self.keymapPresetTitleFormat + let format = String(localized: "command.shortcutKeymap.title", defaultValue: "Base Keymap: %@") let subtitle = String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts") for preset in ShortcutKeymapPreset.allCases { contributions.append( @@ -27,40 +23,22 @@ extension ContentView { } } + /// Opens Settings > Keyboard Shortcuts with the preset's preview. Nothing + /// is written until the user confirms there, the same path as the picker. func registerKeymapPresetCommandHandlers(_ registry: inout CommandPaletteHandlerRegistry) { for preset in ShortcutKeymapPreset.allCases { registry.register(commandId: Self.keymapPresetCommandID(preset)) { - Task { @MainActor in await Self.applyKeymapPreset(preset) } + guard let appDelegate = AppDelegate.shared, + let runtime = appDelegate.settingsRuntime else { + NSSound.beep() + return + } + runtime.keymapProposals.preset = preset + appDelegate.openPreferencesWindow( + debugSource: Self.keymapPresetCommandID(preset), + navigationTarget: .keyboardShortcuts + ) } } } - - /// Applies `preset` through the same plan and store path as the Settings - /// picker, then reports what changed. - @MainActor - private static func applyKeymapPreset(_ preset: ShortcutKeymapPreset) async { - guard let runtime = AppDelegate.shared?.settingsRuntime else { - NSSound.beep() - return - } - let bindingsKey = runtime.catalog.shortcuts.bindingSnapshot - let plan = preset.plan( - from: await runtime.jsonStore.value(for: bindingsKey), - defaultShortcutResolver: runtime.shortcutDefaultResolver - ) - let alert = NSAlert() - do { - try await runtime.jsonStore.applyShortcutKeymap(plan, bindingsID: bindingsKey.id) - runtime.hostActions.notifyShortcutSettingsDidChange() - alert.messageText = String.localizedStringWithFormat(keymapPresetTitleFormat, preset.displayName) - alert.informativeText = ShortcutKeymapPlanText.lines(for: plan).joined(separator: "\n") - alert.alertStyle = plan.systemConflicts.isEmpty ? .informational : .warning - } catch { - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.shortcutKeymap.failed.title", defaultValue: "Keymap Not Changed") - alert.informativeText = error.localizedDescription - } - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - _ = alert.runCmuxModal() - } } diff --git a/skills/cmux-keyboard-shortcuts/SKILL.md b/skills/cmux-keyboard-shortcuts/SKILL.md index b14f66bfae4..c31eced1b8c 100644 --- a/skills/cmux-keyboard-shortcuts/SKILL.md +++ b/skills/cmux-keyboard-shortcuts/SKILL.md @@ -81,7 +81,7 @@ fi ## Preset templates -Settings > Keyboard Shortcuts > Base Keymap (also "Base Keymap: ..." in the Command Palette) ships cmux, iTerm2, Terminal.app, and tmux-style presets. It writes only the overrides that differ from cmux defaults, keeps bindings the user set by hand, and lists macOS shortcut conflicts. Point users there for those styles; use the templates below for the others. +Settings > Keyboard Shortcuts > Base Keymap (also "Base Keymap: ..." in the Command Palette) ships cmux, iTerm2, Terminal.app, and tmux-style presets. It previews the change, then writes only the overrides that differ from cmux defaults, keeps bindings the user set by hand (in `cmux.json` or legacy Settings UserDefaults), and lists macOS shortcut conflicts. Detection is stateless: a binding typed by hand that equals a preset's value for that action is treated as the preset's, so switching presets (including back to cmux) replaces or removes it. Point users there for those styles; use the templates below for the others. Apply action by action, never by overwriting the whole `shortcuts.bindings` object. From 20b4efd047e4858f6d51d312e986adccacffdc44 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 27 Sep 2026 10:05:48 -0400 Subject: [PATCH 3/3] Plan base keymap previews from loaded bindings The Settings row stored a plan computed when the preview opened. A palette-opened preview could be planned before cmux.json bindings had loaded, and Apply then overwrote bindings set by hand; edits made while the preview was open were also ignored. The row now stores only the proposed preset, derives the preview from the current bindings, plans again on Apply, and waits for the first bindings load (ShortcutListModel.hasLoadedBindings) before showing or applying. The display helper is now a private method on ShortcutKeymapPlan. Co-Authored-By: Claude Opus 5.5 --- .../Bindings/ShortcutListModel.swift | 3 + .../Sections/ShortcutKeymapPresetRow.swift | 55 ++++++++++++------- .../Sections/ShortcutKeymapPresetText.swift | 16 +++--- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index 6206202a8c3..5843021fe8a 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -12,6 +12,8 @@ final class ShortcutListModel { var bindings: [String: StoredShortcut] = [:] var managedBindingActionIDs: Set = [] + /// True once the first `shortcuts.bindings` value has arrived from the store. + private(set) var hasLoadedBindings = false var legacyBindings: [String: StoredShortcut] private(set) var whenOverrideClauses: [String: ShortcutWhenClause] = [:] private(set) var whenOverrideRawStrings: [String: String] = [:] @@ -105,6 +107,7 @@ final class ShortcutListModel { .filter { bindings[$0] != dictionary[$0] } bindings = dictionary managedBindingActionIDs = snapshot.managedActionIDs + hasLoadedBindings = true pruneRestoreShortcuts() pruneConflictRejections(changedActionIds: Set(changedActionIds)) pruneNumberedDigitRejections(changedActionIds: Set(changedActionIds)) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift index daf8537ca9c..cbd323eb658 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetRow.swift @@ -5,11 +5,15 @@ import SwiftUI /// The **Base Keymap** picker. Choosing a preset (here or from the Command /// Palette) previews what would change, including shortcuts macOS may take /// first, and writes `shortcuts.bindings` only after the user confirms. +/// +/// Only the chosen preset is stored. The plan is derived from the current +/// bindings on every render and again on Apply, so it never goes stale, and +/// nothing is planned until the store has delivered the first bindings. @MainActor struct ShortcutKeymapPresetRow: View { let model: ShortcutListModel let proposals: ShortcutKeymapProposalInbox? - @State private var proposedPlan: ShortcutKeymapPlan? + @State private var proposedPreset: ShortcutKeymapPreset? @State private var isApplying = false private var snapshot: ShortcutBindingsSnapshot { @@ -27,6 +31,13 @@ struct ShortcutKeymapPresetRow: View { ) } + /// The preview for ``proposedPreset``, or `nil` while nothing is proposed or + /// the bindings haven't loaded yet. + private var proposedPlan: ShortcutKeymapPlan? { + guard let proposedPreset, model.hasLoadedBindings else { return nil } + return plan(for: proposedPreset) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { SettingsCardRow( @@ -42,7 +53,7 @@ struct ShortcutKeymapPresetRow: View { Picker( String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), selection: Binding( - get: { proposedPlan?.preset ?? activePreset }, + get: { proposedPreset ?? activePreset }, set: { preset in if let preset { propose(preset) } } @@ -51,7 +62,7 @@ struct ShortcutKeymapPresetRow: View { ForEach(ShortcutKeymapPreset.allCases, id: \.self) { preset in Text(preset.displayName).tag(Optional(preset)) } - if proposedPlan == nil, activePreset == nil { + if proposedPreset == nil, activePreset == nil { Text(String(localized: "settings.shortcuts.baseKeymap.custom", defaultValue: "Custom")) .tag(ShortcutKeymapPreset?.none) } @@ -92,16 +103,16 @@ struct ShortcutKeymapPresetRow: View { HStack(spacing: 8) { Spacer() Button(String(localized: "common.cancel", defaultValue: "Cancel")) { - proposedPlan = nil + proposedPreset = nil } .controlSize(.small) .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapCancel") Button(String(localized: "settings.shortcuts.baseKeymap.apply", defaultValue: "Apply Keymap")) { - apply(plan) + apply(plan.preset) } .buttonStyle(.borderedProminent) .controlSize(.small) - .disabled(plan.isEmpty || isApplying) + .disabled(plan.isEmpty || isApplying || !model.hasLoadedBindings) .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapApply") } .padding(.top, 4) @@ -111,36 +122,40 @@ struct ShortcutKeymapPresetRow: View { .accessibilityIdentifier("SettingsKeyboardShortcutsBaseKeymapPreview") } - private func propose(_ preset: ShortcutKeymapPreset) { - guard preset != activePreset else { - proposedPlan = nil - return - } - proposedPlan = preset.plan( + private func plan(for preset: ShortcutKeymapPreset) -> ShortcutKeymapPlan { + preset.plan( from: snapshot, legacyBindings: model.legacyBindings, defaultShortcutResolver: model.defaultShortcutResolver ) } + private func propose(_ preset: ShortcutKeymapPreset) { + proposedPreset = preset == activePreset ? nil : preset + } + private func takeProposal() { guard let proposals, let preset = proposals.preset else { return } proposals.preset = nil - proposedPlan = preset.plan( - from: snapshot, - legacyBindings: model.legacyBindings, - defaultShortcutResolver: model.defaultShortcutResolver - ) + proposedPreset = preset } - private func apply(_ plan: ShortcutKeymapPlan) { + /// Plans again from the bindings as they are now, so edits made while the + /// preview was open are respected. + private func apply(_ preset: ShortcutKeymapPreset) { + guard model.hasLoadedBindings else { return } + let freshPlan = plan(for: preset) + guard !freshPlan.isEmpty else { + proposedPreset = nil + return + } isApplying = true Task { defer { isApplying = false } do { - try await model.jsonStore.applyShortcutKeymap(plan, bindingsID: model.catalog.shortcuts.bindings.id) + try await model.jsonStore.applyShortcutKeymap(freshPlan, bindingsID: model.catalog.shortcuts.bindings.id) model.onShortcutsChanged() - proposedPlan = nil + proposedPreset = nil } catch { model.errorLog.record(error, keyID: model.catalog.shortcuts.bindings.id) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift index 3fe7efeacce..b32f2878fc1 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutKeymapPresetText.swift @@ -107,26 +107,26 @@ extension ShortcutKeymapPlan { String.localizedStringWithFormat( changeFormat, change.action.displayName, - keymapDisplayString(change.before, for: change.action), - keymapDisplayString(change.after, for: change.action) + displayString(change.before, for: change.action), + displayString(change.after, for: change.action) ) } for change in systemConflicts { let names = change.systemConflicts.map(\.displayName).joined(separator: ", ") lines.append(String.localizedStringWithFormat( conflictFormat, - keymapDisplayString(change.after, for: change.action), + displayString(change.after, for: change.action), names )) } lines += kept.map { String.localizedStringWithFormat(keptFormat, $0.displayName) } return lines } -} -private func keymapDisplayString(_ shortcut: StoredShortcut, for action: ShortcutAction) -> String { - guard !shortcut.isUnbound else { - return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + private func displayString(_ shortcut: StoredShortcut, for action: ShortcutAction) -> String { + guard !shortcut.isUnbound else { + return String(localized: "shortcut.unbound.displayValue", defaultValue: "None") + } + return shortcutDisplayString(shortcut, numbered: action.usesNumberedDigitMatching) } - return shortcutDisplayString(shortcut, numbered: action.usesNumberedDigitMatching) }