136 lines
6.0 KiB
Swift
136 lines
6.0 KiB
Swift
import Foundation
|
|
|
|
public struct KeyModifiers: OptionSet, Hashable, Sendable {
|
|
public let rawValue: UInt8
|
|
public init(rawValue: UInt8) { self.rawValue = rawValue }
|
|
public static let command = Self(rawValue: 1 << 0)
|
|
public static let control = Self(rawValue: 1 << 1)
|
|
public static let option = Self(rawValue: 1 << 2)
|
|
public static let shift = Self(rawValue: 1 << 3)
|
|
}
|
|
|
|
public struct KeyChord: Hashable, Sendable {
|
|
public let key: String
|
|
public let modifiers: KeyModifiers
|
|
|
|
public init(key: String, modifiers: KeyModifiers) {
|
|
self.key = key
|
|
self.modifiers = modifiers
|
|
}
|
|
|
|
public init(_ text: String) throws {
|
|
let parts = text.lowercased().split(separator: "+", omittingEmptySubsequences: false)
|
|
func invalid(_ reason: String) -> BindingError {
|
|
BindingError(message: "Invalid chord '\(text)': \(reason)")
|
|
}
|
|
guard let last = parts.last, !last.isEmpty, parts.allSatisfy({ !$0.isEmpty }) else {
|
|
throw invalid("empty key or modifier; use 'plus' for the + key.")
|
|
}
|
|
var modifiers: KeyModifiers = []
|
|
for part in parts.dropLast() {
|
|
let modifier: KeyModifiers
|
|
switch part {
|
|
case "cmd": modifier = .command
|
|
case "ctrl": modifier = .control
|
|
case "alt", "option": modifier = .option
|
|
case "shift": modifier = .shift
|
|
default: throw invalid("unknown modifier '\(part)'.")
|
|
}
|
|
guard !modifiers.contains(modifier) else { throw invalid("duplicate modifier '\(part)'.") }
|
|
modifiers.insert(modifier)
|
|
}
|
|
let key = String(last)
|
|
let namedKeys: Set<String> = ["space", "escape", "return", "tab", "backspace", "delete",
|
|
"left", "right", "up", "down", "home", "end", "pageup", "pagedown", "plus"]
|
|
let functionKey: Bool
|
|
if key.hasPrefix("f"), let number = Int(key.dropFirst()) {
|
|
functionKey = (1...35).contains(number) && key == "f\(number)"
|
|
} else {
|
|
functionKey = false
|
|
}
|
|
let printable = key.count == 1 && key != "+" && key.unicodeScalars.allSatisfy {
|
|
!CharacterSet.controlCharacters.contains($0) && !CharacterSet.whitespacesAndNewlines.contains($0)
|
|
}
|
|
guard namedKeys.contains(key) || functionKey || printable else { throw invalid("unknown key '\(key)'.") }
|
|
guard !(key == "tab" && modifiers.contains(.command)) else {
|
|
throw invalid("unsupported OS-reserved cmd+tab shortcut.")
|
|
}
|
|
self.init(key: key == "plus" ? "+" : key, modifiers: modifiers)
|
|
}
|
|
|
|
public var description: String {
|
|
var parts: [String] = []
|
|
if modifiers.contains(.control) { parts.append("ctrl") }
|
|
if modifiers.contains(.option) { parts.append("alt") }
|
|
if modifiers.contains(.shift) { parts.append("shift") }
|
|
if modifiers.contains(.command) { parts.append("cmd") }
|
|
parts.append(key == "+" ? "plus" : key)
|
|
return parts.joined(separator: "+")
|
|
}
|
|
}
|
|
|
|
public struct KeyBindings: Equatable, Sendable {
|
|
private let byAction: [PlayerAction: [KeyChord]]
|
|
private let byChord: [KeyChord: [PlayerAction]]
|
|
|
|
private static let builtIn: [PlayerAction: [KeyChord]] = {
|
|
let texts: [PlayerAction: [String]] = [
|
|
.play: ["k", "space"], .pause: ["k", "space"],
|
|
.mute: ["m"], .unmute: ["m"], .volumeUp: ["up"], .volumeDown: ["down"],
|
|
.seekForward5s: ["right"], .seekBackward5s: ["left"],
|
|
.seekForward10s: ["l"], .seekBackward10s: ["j"],
|
|
.stepFrameForward: ["."], .stepFrameBackward: [","],
|
|
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
|
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
|
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
|
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
|
|
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
|
]
|
|
return texts.mapValues { $0.map { try! KeyChord($0) } }
|
|
}()
|
|
private static let defaults = try! KeyBindings(overrides: [:])
|
|
|
|
public init() { self = Self.defaults }
|
|
|
|
public init(overrides: [String: [String]]) throws {
|
|
var bindings = Self.builtIn
|
|
for name in overrides.keys.sorted() {
|
|
guard let action = PlayerAction(name: name) else {
|
|
throw BindingError(message: "Unknown action 'keys.\(name)'.")
|
|
}
|
|
var chords: [KeyChord] = []
|
|
var seen: Set<KeyChord> = []
|
|
for text in overrides[name]! {
|
|
let chord: KeyChord
|
|
do { chord = try KeyChord(text) }
|
|
catch { throw BindingError(message: "keys.\(name): \(error.localizedDescription)") }
|
|
guard seen.insert(chord).inserted else {
|
|
throw BindingError(message: "keys.\(name): duplicate chord '\(text)' (\(chord.description)).")
|
|
}
|
|
chords.append(chord)
|
|
}
|
|
if chords.isEmpty { bindings.removeValue(forKey: action) }
|
|
else { bindings[action] = chords }
|
|
}
|
|
var lookup: [KeyChord: [PlayerAction]] = [:]
|
|
for action in bindings.keys.sorted(by: { $0.name < $1.name }) {
|
|
for chord in bindings[action]! {
|
|
if let existing = lookup[chord]?.first, existing.inverse != action {
|
|
throw BindingError(message: "Chord '\(chord.description)' conflicts between keys.\(existing.name) and keys.\(action.name).")
|
|
}
|
|
lookup[chord, default: []].append(action)
|
|
}
|
|
}
|
|
byAction = bindings
|
|
byChord = lookup
|
|
}
|
|
|
|
public func shortcuts(for action: PlayerAction) -> [KeyChord] { byAction[action] ?? [] }
|
|
public func actions(for chord: KeyChord) -> [PlayerAction] { byChord[chord] ?? [] }
|
|
}
|
|
|
|
struct BindingError: LocalizedError {
|
|
let message: String
|
|
var errorDescription: String? { message }
|
|
}
|