Sources/MiriCore/Bindings.swift
import Foundation
/// A modifier key in the engine's chord syntax.
public enum ChordModifier: String, CaseIterable, Comparable, Sendable {
case control = "ctrl"
case option = "alt"
case command = "cmd"
case shift = "shift"
case function = "fn"
/// The engine's side-specific names match either side of the same key, so they count as the generic modifier.
static func parse(_ name: String) -> ChordModifier? {
switch name.lowercased() {
case "ctrl", "lctrl", "rctrl": .control
case "alt", "lalt", "ralt": .option
case "cmd", "lcmd", "rcmd": .command
case "shift", "lshift", "rshift": .shift
case "fn": .function
default: nil
}
}
public static func < (lhs: Self, rhs: Self) -> Bool {
allCases.firstIndex(of: lhs)! < allCases.firstIndex(of: rhs)!
}
}
/// A key press with its modifiers, such as `ctrl+alt-leftarrow`.
public struct Chord: Hashable, Sendable, CustomStringConvertible {
public let modifiers: Set<ChordModifier>
/// The engine's key name, such as `h`, `leftarrow`, or `equal`.
public let key: String
public init(_ modifiers: Set<ChordModifier>, _ key: String) {
self.modifiers = modifiers
self.key = key
}
/// Reads a chord in the engine's syntax to find which chords a file already uses.
///
/// This is deliberately more lenient than the engine. It ignores case, so it reads `Ctrl-H` as `ctrl-h` where
/// the engine rejects the modifier (`vendor/paneru/src/config.rs` line 1736) and finds no key named `H`
/// (line 1718). A near miss therefore still counts as taking its chord, and Miri adds no second binding for it.
/// Returns `nil` for text that names no single chord, such as an empty key or an unknown modifier.
public init?(engineText: String) {
guard let exact = Chord(exactEngineText: engineText) else { return nil }
self.init(exact.modifiers, Self.keyAliases[exact.key] ?? exact.key)
}
/// Like `init(engineText:)`, but keeps a printed character such as `=` as written, because only the keyboard
/// layout says which key it names. `KeyLayout.conflicts` compares chords read this way.
init?(exactEngineText engineText: String) {
let parts = engineText.unicodeScalars.split(separator: "-", omittingEmptySubsequences: false).map {
String($0).trimmingCharacters(in: EngineKey.whitespace)
}
guard (1...2).contains(parts.count), let last = parts.last, !last.isEmpty else { return nil }
var modifiers = Set<ChordModifier>()
if parts.count == 2 {
for name in parts[0].unicodeScalars.split(separator: "+", omittingEmptySubsequences: false) {
guard let modifier = ChordModifier.parse(String(name).trimmingCharacters(in: EngineKey.whitespace))
else {
return nil
}
modifiers.insert(modifier)
}
}
self.init(modifiers, last.lowercased())
}
/// The engine's syntax, with modifiers in a fixed order.
public var description: String {
let names = modifiers.sorted().map(\.rawValue).joined(separator: "+")
return names.isEmpty ? key : "\(names)-\(key)"
}
/// On a US layout the engine also accepts a key's printed character, so both spellings name the same key.
private static let keyAliases = [
"=": "equal", "[": "leftbracket", "]": "rightbracket", ";": "semicolon", "'": "quote", ",": "comma",
".": "period", "/": "slash", "\\": "backslash", "`": "grave",
]
}
enum BindingInspection {
case usable(droppedKeyIssue: ConfigurationIssue?)
case rejected(ConfigurationIssue)
var issue: ConfigurationIssue? {
switch self {
case .usable(let issue): issue
case .rejected(let issue): issue
}
}
}
/// One action in Miri's default shortcut set and the engine command that performs it.
public struct DefaultBinding: Sendable {
/// The action's name in the keybinding reference.
public let action: String
/// The engine's `[bindings]` key, which the engine splits on `_` into its command words.
public let command: String
public let chords: [Chord]
/// The `miri.default_bindings_version` that first offered this default. Later versions add only newer rows.
/// A row with no chords lists an action that Settings offers without a default shortcut.
public let version: Int
init(_ action: String, _ command: String, _ chords: Chord..., version: Int = 1) {
self.action = action
self.command = command
self.chords = chords
self.version = version
}
/// The accepted defaults from `decisions/keybinding reference.md` that the pinned engine can perform.
///
/// The reference also lists actions this table leaves unbound, because the engine has no command for them or
/// the action is app-owned or deferred to v2. `decisions/keybinding reference.md` names each one and the reason.
public static let table: [DefaultBinding] =
[
DefaultBinding("Focus column left", "window_focus_west", ctrlAlt("leftarrow"), ctrlAlt("h"), ctrlAlt("a")),
DefaultBinding(
"Focus column right", "window_focus_east", ctrlAlt("rightarrow"), ctrlAlt("l"), ctrlAlt("d")),
DefaultBinding(
"Focus window above within column", "window_focus_north", ctrlAlt("uparrow"), ctrlAlt("k"),
ctrlAlt("w")),
DefaultBinding(
"Focus window below within column", "window_focus_south", ctrlAlt("downarrow"), ctrlAlt("j"),
ctrlAlt("s")),
DefaultBinding(
"Focus monitor left", "window_focusdisplay_west", ctrlAlt("leftarrow", .command),
ctrlAlt("h", .command), ctrlAlt("a", .command), version: 5),
DefaultBinding(
"Focus monitor right", "window_focusdisplay_east", ctrlAlt("rightarrow", .command),
ctrlAlt("l", .command), ctrlAlt("d", .command), version: 5),
DefaultBinding(
"Focus monitor above", "window_focusdisplay_north", ctrlAlt("uparrow", .command),
ctrlAlt("k", .command), ctrlAlt("w", .command), version: 2),
DefaultBinding(
"Focus monitor below", "window_focusdisplay_south", ctrlAlt("downarrow", .command),
ctrlAlt("j", .command), ctrlAlt("s", .command), version: 2),
]
+ (1...9).map { DefaultBinding("Jump to column \($0)", "window_focus_\($0)", ctrlAlt("\($0)")) }
+ [
DefaultBinding(
"Move column left", "window_swap_west", ctrlAlt("leftarrow", .shift), ctrlAlt("h", .shift),
ctrlAlt("a", .shift)),
DefaultBinding(
"Move column right", "window_swap_east", ctrlAlt("rightarrow", .shift), ctrlAlt("l", .shift),
ctrlAlt("d", .shift)),
DefaultBinding(
"Move window up within column", "window_swap_north", ctrlAlt("uparrow", .shift), ctrlAlt("k", .shift),
ctrlAlt("w", .shift)),
DefaultBinding(
"Move window down within column", "window_swap_south", ctrlAlt("downarrow", .shift),
ctrlAlt("j", .shift), ctrlAlt("s", .shift)),
DefaultBinding(
"Move column to first position or monitor left", "window_moveedge_west",
ctrlAlt("leftarrow", .command, .shift), ctrlAlt("h", .command, .shift),
ctrlAlt("a", .command, .shift), version: 4),
DefaultBinding(
"Move column to last position or monitor right", "window_moveedge_east",
ctrlAlt("rightarrow", .command, .shift), ctrlAlt("l", .command, .shift),
ctrlAlt("d", .command, .shift), version: 4),
DefaultBinding(
"Move column to monitor above", "window_movedisplay_north", ctrlAlt("uparrow", .command, .shift),
ctrlAlt("k", .command, .shift), ctrlAlt("w", .command, .shift), version: 2),
DefaultBinding(
"Move column to monitor below", "window_movedisplay_south", ctrlAlt("downarrow", .command, .shift),
ctrlAlt("j", .command, .shift), ctrlAlt("s", .command, .shift), version: 2),
DefaultBinding("Send column to next display", "window_nextdisplay", ctrlAlt("n")),
DefaultBinding("Cycle preset widths forward", "window_resize", ctrlAlt("r")),
// Version 1 offered this as `window_fullwidth`, which the engine still reads as `window_maximize`.
DefaultBinding("Maximize or restore window", "window_maximize", ctrlAlt("f")),
DefaultBinding("Centre column", "window_center", ctrlAlt("c")),
DefaultBinding("Make column wider", "window_step_grow", ctrlAlt("equal"), version: 2),
DefaultBinding("Make column narrower", "window_step_shrink", ctrlAlt("minus"), version: 2),
DefaultBinding("Make window taller", "window_vertical_step_grow", ctrlAlt("equal", .shift), version: 2),
DefaultBinding("Make window shorter", "window_vertical_step_shrink", ctrlAlt("minus", .shift), version: 2),
DefaultBinding("Equalize window heights within column", "window_equalize", ctrlAlt("0")),
DefaultBinding("Stack focused window into left column", "window_stack_west", ctrlAlt("leftbracket")),
DefaultBinding("Stack focused window into right column", "window_stack_east", ctrlAlt("rightbracket")),
DefaultBinding("Pop focused window out to right", "window_unstack_east", ctrlAlt("rightbracket", .shift)),
DefaultBinding("Pop focused window out to left", "window_unstack_west", ctrlAlt("leftbracket", .shift)),
DefaultBinding("Float or unfloat focused window", "window_manage", ctrlAlt("t")),
DefaultBinding("Focus first column", "window_focus_first"),
DefaultBinding("Focus last column", "window_focus_last"),
DefaultBinding("Move column to first position", "window_swap_first"),
DefaultBinding("Move column to last position", "window_swap_last"),
DefaultBinding("Focus first column or monitor left", "window_focusedge_west", version: 5),
DefaultBinding("Focus last column or monitor right", "window_focusedge_east", version: 5),
DefaultBinding("Move column to monitor left", "window_movedisplay_west", version: 2),
DefaultBinding("Move column to monitor right", "window_movedisplay_east", version: 2),
]
/// The newest version in the table or in `changed`. Miri records it after offering the defaults.
public static let currentVersion = (table.map(\.version) + changed.map(\.version)).max() ?? 0
/// Defaults that `version` removed, with the command that replaces each one. `offered` is the version that first
/// wrote the retired default, so Miri can tell a binding the user deleted from one it never wrote.
static let retired: [(command: String, replacement: String, chords: [Chord], offered: Int, version: Int)] = [
("window_grow", "window_step_grow", [ctrlAlt("equal")], 1, 2),
("window_shrink", "window_step_shrink", [ctrlAlt("minus")], 1, 2),
("window_vertical_grow", "window_vertical_step_grow", [ctrlAlt("equal", .shift)], 1, 2),
("window_vertical_shrink", "window_vertical_step_shrink", [ctrlAlt("minus", .shift)], 1, 2),
("window_focus_first", "window_focusdisplay_west", [ctrlAlt("leftarrow", .command)], 1, 4),
("window_focusdisplay_west", "window_focusdisplay_west", [ctrlAlt("a", .command)], 2, 4),
("window_focus_last", "window_focusdisplay_east", [ctrlAlt("rightarrow", .command)], 1, 4),
("window_focusdisplay_east", "window_focusdisplay_east", [ctrlAlt("d", .command)], 2, 4),
("window_swap_first", "window_moveedge_west", [ctrlAlt("leftarrow", .command, .shift)], 1, 4),
("window_movedisplay_west", "window_moveedge_west", [ctrlAlt("a", .command, .shift)], 2, 4),
("window_swap_last", "window_moveedge_east", [ctrlAlt("rightarrow", .command, .shift)], 1, 4),
("window_movedisplay_east", "window_moveedge_east", [ctrlAlt("d", .command, .shift)], 2, 4),
(
"window_focusedge_west", "window_focusdisplay_west",
[ctrlAlt("leftarrow", .command), ctrlAlt("h", .command), ctrlAlt("a", .command)], 4, 5
),
(
"window_focusedge_east", "window_focusdisplay_east",
[ctrlAlt("rightarrow", .command), ctrlAlt("l", .command), ctrlAlt("d", .command)], 4, 5
),
]
/// Defaults whose chords changed in `version`. Version 3 makes `[` mean left and `]` mean right (miri#92).
/// Version 4 gives every directional action the same chords on the arrow keys, HJKL, and WASD (miri#79).
static let changed: [(command: String, from: [Chord], to: [Chord], version: Int)] =
[
("window_stack_west", [ctrlAlt("rightbracket")], [ctrlAlt("leftbracket")], 3),
("window_stack_east", [ctrlAlt("leftbracket")], [ctrlAlt("rightbracket")], 3),
]
+ [
("window_focus_west", ["a"]), ("window_focus_east", ["d"]), ("window_focus_north", ["w"]),
("window_focus_south", ["s"]), ("window_swap_west", ["a"]), ("window_swap_east", ["d"]),
("window_swap_north", ["w"]), ("window_swap_south", ["s"]),
("window_focusdisplay_north", ["uparrow", "k"]), ("window_focusdisplay_south", ["downarrow", "j"]),
("window_movedisplay_north", ["uparrow", "k"]), ("window_movedisplay_south", ["downarrow", "j"]),
].map { command, added in
let to = table.first { $0.command == command }!.chords
return (command, to.filter { !added.contains($0.key) }, to, 4)
}
/// Settings reports each chord of an action first offered in this version or later that another binding holds.
/// Earlier versions added an action's free chords without a report, and their files keep that behavior.
static let reportsHeldChordsSince = 4
/// The default Stop window manager shortcut, used when `[miri].stop_shortcut` is absent. Miri owns Stop instead of
/// the engine, because the engine's `quit` makes Miri restart the engine (miri#25).
public static let stopChord = ctrlAlt("q")
private static func ctrlAlt(_ key: String, _ extra: ChordModifier...) -> Chord {
Chord(Set([.control, .option] + extra), key)
}
/// Adds each default newer than the recorded version whose engine command has no binding, with its free chords.
///
/// A user binding is never changed. Miri changes only a retired or changed default that still has the exact value
/// Miri wrote. A default is offered once, so a binding the user deletes stays deleted.
/// When the file defines `[bindings]` or `[miri]` in a form Miri does not rewrite, such as an inline table, Miri
/// leaves the file as it is and records no version, so it offers the defaults again if the user converts the table.
static func adding(to document: TOMLDocument, layout: KeyLayout = .positional) throws -> TOMLDocument {
do {
return try addingEditable(to: document, layout: layout)
} catch TOMLEditError.notEditable {
return document
}
}
private static func addingEditable(to document: TOMLDocument, layout: KeyLayout) throws -> TOMLDocument {
let versionPath = ["miri", "default_bindings_version"]
let offered: Int
switch document.value(at: versionPath) {
case nil: offered = 0
case .integer(let version): offered = Int(clamping: version)
default:
throw ConfigurationIssue.invalidValue(
key: "miri.default_bindings_version", message: "miri.default_bindings_version must be a whole number.")
}
guard offered < currentVersion else { return document }
var result = document
let unwanted = replacementsToSkip(in: document, offered: offered)
for old in retired where offered >= old.offered && old.version > offered {
guard document.value(at: ["bindings", old.command]) == encoded(old.chords) else { continue }
let leavesUnbound = table.contains {
$0.command == old.command && $0.version == old.version && $0.chords.isEmpty
}
if leavesUnbound {
guard
!boundCommands(in: document.value(at: ["bindings"])).contains(
EngineCommand.canonical(old.replacement)!)
else { continue }
result = try result.setting(.array([]), table: ["bindings"], key: old.command)
} else {
result = try result.removing(table: ["bindings"], key: old.command)
}
}
result = try changing(in: result, offered: offered, layout: layout)
let existing = result.value(at: ["bindings"])
var taken = takenChords(in: result, layout: layout)
// Only the file's own bindings count, so two default rows for one engine command both get written.
let bound = Set(boundCommands(in: existing))
for row in table where row.version > offered && !bound.contains(EngineCommand.canonical(row.command)!) {
guard !unwanted.contains(row.command) else { continue }
let free = row.chords.filter { chord in !taken.contains { layout.conflicts($0, chord) } }
guard !free.isEmpty else { continue }
taken += free
result = try result.setting(encoded(free), table: ["bindings"], key: row.command)
}
return try result.setting(.integer(Int64(currentVersion)), table: ["miri"], key: "default_bindings_version")
}
/// The replacements for retired defaults that Miri must not add.
///
/// In a recorded version, a replacement is skipped when the user deleted or emptied every retired default it
/// replaces that Miri had written. A file with no recorded version keeps its bindings, so a replacement is skipped while any
/// binding it replaces remains, because the two would split the same chords between old and new actions.
private static func replacementsToSkip(in document: TOMLDocument, offered: Int) -> Set<String> {
let bound = Set(boundCommands(in: document.value(at: ["bindings"])))
let pending = Dictionary(grouping: retired.filter { $0.version > offered }, by: \.replacement)
return Set(
pending.filter { _, olds in
if offered == 0 { return olds.contains { bound.contains(EngineCommand.canonical($0.command)!) } }
let written = olds.filter { $0.offered <= offered }
return !written.isEmpty
&& written.allSatisfy { [nil, .array([])].contains(document.value(at: ["bindings", $0.command])) }
}.keys)
}
/// Gives each `changed` default newer than `offered` its new chords, but only while the binding still has the
/// exact value Miri wrote and every chord the change adds is free. Otherwise the binding keeps its value, and
/// `keptChanges` finds it.
private static func changing(in document: TOMLDocument, offered: Int, layout: KeyLayout) throws -> TOMLDocument {
var changes = changed.filter { change in
offered >= 1 && change.version > offered
&& document.value(at: ["bindings", change.command]) == encoded(change.from)
}
// A change whose new chord another binding holds keeps its old chords, which can block another change in turn.
while true {
var others = document
for change in changes { others = try others.removing(table: ["bindings"], key: change.command) }
let taken = takenChords(in: others, layout: layout)
let blocked = Set(
changes.filter { change in
added(by: change).contains { chord in taken.contains { layout.conflicts($0, chord) } }
}.map(\.command))
guard !blocked.isEmpty else { break }
changes.removeAll { blocked.contains($0.command) }
}
var result = document
for change in changes {
result = try result.setting(encoded(change.to), table: ["bindings"], key: change.command)
}
return result
}
/// The `changed` defaults that `document` records as offered but that still have the exact value Miri wrote
/// before the change, with the chords the change would have added.
static func keptChanges(in document: TOMLDocument) -> [(command: String, kept: [Chord], wanted: [Chord])] {
guard case .integer(let offered) = document.value(at: ["miri", "default_bindings_version"]) else { return [] }
return changed.filter { change in
change.version <= offered && document.value(at: ["bindings", change.command]) == encoded(change.from)
}.map { ($0.command, $0.from, added(by: $0)) }
}
private static func added(by change: (command: String, from: [Chord], to: [Chord], version: Int)) -> [Chord] {
change.to.filter { !change.from.contains($0) }
}
/// One chord as a string and several as an array, as Miri writes its defaults.
static func encoded(_ chords: [Chord]) -> TOMLValue {
chords.count == 1 ? .string(chords[0].description) : .array(chords.map { .string($0.description) })
}
/// The chords a new default must not take: every binding's, and Stop's, because an engine binding wins over Stop
/// on the same chord.
private static func takenChords(in document: TOMLDocument, layout: KeyLayout) -> [Chord] {
var taken = boundChords(in: document.value(at: ["bindings"]), layout: layout)
if let stop = document.value(at: ["miri", "stop_shortcut"]) {
taken += boundChords(in: .table(["stop_shortcut": stop]), layout: layout)
} else {
taken.append(stopChord)
}
return taken
}
/// Each bound command in the engine's own spelling, so that an alias such as `window_resize` counts as
/// `window_grow`. A key the engine rejects keeps its own spelling.
private static func boundCommands(in bindings: TOMLValue?) -> [String] {
guard case .table(let entries) = bindings else { return [] }
return entries.keys.map { EngineCommand.canonical($0) ?? $0 }
}
static func inspect(in document: TOMLDocument, layout: KeyLayout = .positional) -> BindingInspection {
guard let bindings = document.value(at: ["bindings"]) else { return .usable(droppedKeyIssue: nil) }
guard case .table(let entries) = bindings else {
return .rejected(.invalidValue(key: "bindings", message: "bindings must be a table of shortcuts."))
}
var droppedKeyIssue: ConfigurationIssue?
for (command, value) in entries.sorted(by: { $0.key < $1.key }) {
let path = "bindings.\(command)"
let values: [TOMLValue]
switch value {
case .array(let items): values = items
default: values = [value]
}
var texts: [String] = []
for value in values {
guard case .string(let text) = value else {
return .rejected(
.invalidValue(
key: path, message: "\(path) must be a shortcut string or an array of shortcut strings."))
}
texts.append(text)
}
guard texts.isEmpty || EngineCommand.canonical(command) != nil else {
return .rejected(.invalidValue(key: path, message: "\(path) names a command the engine does not know."))
}
for text in texts {
guard let problem = EngineKey.problem(in: text, layout: layout) else { continue }
let issue = ConfigurationIssue.invalidValue(key: path, message: "\(path): \(problem.message)")
switch problem {
case .unknownKey: droppedKeyIssue = droppedKeyIssue ?? issue
case .extraDash, .invalidModifier: return .rejected(issue)
}
}
}
return .usable(droppedKeyIssue: droppedKeyIssue)
}
/// A printed character such as `]` names whichever key types it in `layout`, so it keeps its spelling there.
/// Only a character the layout cannot resolve falls back to its US key name.
private static func boundChords(in bindings: TOMLValue?, layout: KeyLayout) -> [Chord] {
bindingTexts(in: bindings).compactMap { text in
guard let exact = Chord(exactEngineText: text) else { return nil }
return layout.keyCode(for: exact.key) == nil ? Chord(engineText: text) : exact
}
}
private static func bindingTexts(in bindings: TOMLValue?) -> [String] {
guard case .table(let entries) = bindings else { return [] }
return entries.values.flatMap { value -> [String] in
switch value {
case .string(let text): [text]
case .array(let items): items.compactMap { if case .string(let text) = $0 { text } else { nil } }
default: []
}
}
}
}