Files
Termind/TFM/Platform/AppShortcut.swift
T
2026-09-19 12:30:16 +08:00

114 lines
3.8 KiB
Swift

import AppKit
import SwiftUI
enum AppShortcutKey: Hashable, Sendable, ExpressibleByExtendedGraphemeClusterLiteral {
case character(Character)
case tab
case escape
case f3
case rightArrow
case downArrow
init(extendedGraphemeClusterLiteral value: Character) { self = .character(value) }
var keyEquivalent: KeyEquivalent {
switch self {
case .character(let character): KeyEquivalent(character)
case .tab: .tab
case .escape: .escape
case .f3: KeyEquivalent(Character(UnicodeScalar(NSF3FunctionKey)!))
case .rightArrow: .rightArrow
case .downArrow: .downArrow
}
}
}
struct AppShortcutRegistration: Hashable, Sendable {
let key: AppShortcutKey
let modifiers: EventModifiers
init(_ key: AppShortcutKey, modifiers: EventModifiers = .command) {
self.key = key
self.modifiers = modifiers
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.key == rhs.key && lhs.appKitModifiers == rhs.appKitModifiers
}
func hash(into hasher: inout Hasher) {
hasher.combine(key)
hasher.combine(appKitModifiers.rawValue)
}
var appKitModifiers: NSEvent.ModifierFlags {
var result: NSEvent.ModifierFlags = []
if modifiers.contains(.command) { result.insert(.command) }
if modifiers.contains(.control) { result.insert(.control) }
if modifiers.contains(.option) { result.insert(.option) }
if modifiers.contains(.shift) { result.insert(.shift) }
return result
}
func matches(_ event: NSEvent) -> Bool {
guard event.type == .keyDown else { return false }
let significantModifiers = event.modifierFlags.intersection([
.command, .control, .option, .shift,
])
guard significantModifiers == appKitModifiers else { return false }
switch key {
case .tab: return event.keyCode == 48
case .escape: return event.keyCode == 53
// Key codes, like `.tab` above: the terminal has to recognise an arrow
// and yield it before Ghostty encodes it as an escape sequence, and a
// layout-independent comparison is the one that cannot miss.
case .rightArrow: return event.keyCode == 124
case .downArrow: return event.keyCode == 125
case .f3:
return event.charactersIgnoringModifiers?.unicodeScalars.first?.value
== UInt32(NSF3FunctionKey)
case .character(let character):
let expected = String(character).lowercased()
return event.characters(byApplyingModifiers: [])?.lowercased() == expected
|| event.charactersIgnoringModifiers?.lowercased() == expected
}
}
}
/// Stores declarations supplied by command definitions; it owns no concrete keys.
final class AppShortcutRegistry {
enum TerminalRouting: Sendable {
case application
case terminal
}
static let shared = AppShortcutRegistry()
private var applicationShortcuts: Set<AppShortcutRegistration> = []
func register(
_ shortcut: AppShortcutRegistration,
terminalRouting: TerminalRouting = .application
) {
guard terminalRouting == .application else { return }
applicationShortcuts.insert(shortcut)
}
func routesToApplication(_ event: NSEvent) -> Bool {
applicationShortcuts.contains { $0.matches(event) }
}
}
extension View {
func termindShortcut(
_ key: AppShortcutKey,
modifiers: EventModifiers = .command,
terminalRouting: AppShortcutRegistry.TerminalRouting = .application
) -> some View {
let shortcut = AppShortcutRegistration(key, modifiers: modifiers)
AppShortcutRegistry.shared.register(shortcut, terminalRouting: terminalRouting)
return keyboardShortcut(key.keyEquivalent, modifiers: modifiers)
}
}