Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ShortcutKeymapBinding>(
id: "\(bindingsID).\(change.action.rawValue)",
defaultValue: .unbound
)
if let binding = change.write {
try await set(binding, for: key)
} else {
try await reset(key)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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, 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
/// 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`.
/// - 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
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()
// 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 }
if !managed && target == before { continue }
guard !setByHand else {
kept.append(action)
continue
}
changes.append(ShortcutKeymapChange(
action: action,
before: before,
after: target,
write: binding,
systemConflicts: MacOSSystemShortcut.conflicts(with: target, for: action)
))
} else if ownedByPreset {
let after = legacyValue ?? defaultShortcut
changes.append(ShortcutKeymapChange(
action: action,
before: before,
after: after,
write: nil,
systemConflicts: MacOSSystemShortcut.conflicts(with: after, 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,
legacyBindings: [String: StoredShortcut] = [:],
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,
legacyBindings: legacyBindings,
defaultShortcutResolver: defaultShortcutResolver
).isEmpty {
return preset
}
}
return ShortcutKeymapPreset.cmux
.plan(from: snapshot, legacyBindings: legacyBindings, defaultShortcutResolver: defaultShortcutResolver)
.isEmpty ? .cmux : nil
}

private static func isPresetValue(_ shortcut: StoredShortcut, for action: ShortcutAction) -> Bool {
allCases.contains { $0.overrides[action]?.shortcut == shortcut }
}
}
Loading
Loading