feat: configure explicit state-dependent player keybindings
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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"], .openYoutube: ["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 }
|
||||
}
|
||||
Reference in New Issue
Block a user