Files
terebi/Sources/Configuration/KeyBindings.swift
T

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"], .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 }
}