mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
BREAKING CHANGE: Package name changed due to name collision with existing rensbreur/SwiftTUI package. Changes: - Rename package from SwiftTUI to TUIKit in Package.swift - Rename Sources/SwiftTUI to Sources/TUIKit - Rename Sources/SwiftTUIExample to Sources/TUIKitExample - Rename Tests/SwiftTUITests to Tests/TUIKitTests - Rename SwiftTUI.swift to TUIKit.swift - Update all imports: import SwiftTUI -> import TUIKit - Update all code references: SwiftTUI.renderToBuffer -> TUIKit.renderToBuffer - Update documentation comments - Rename swiftTUIVersion to tuiKitVersion All 181 tests passing.
248 lines
6.7 KiB
Swift
248 lines
6.7 KiB
Swift
//
|
|
// KeyEvent.swift
|
|
// TUIKit
|
|
//
|
|
// Keyboard event handling for TUIKit.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Key Event
|
|
|
|
/// Represents a keyboard event.
|
|
public struct KeyEvent: Equatable, Sendable {
|
|
/// The key that was pressed.
|
|
public let key: Key
|
|
|
|
/// Whether the Ctrl modifier was held.
|
|
public let ctrl: Bool
|
|
|
|
/// Whether the Alt/Option modifier was held.
|
|
public let alt: Bool
|
|
|
|
/// Whether the Shift modifier was held.
|
|
public let shift: Bool
|
|
|
|
/// Creates a key event.
|
|
public init(key: Key, ctrl: Bool = false, alt: Bool = false, shift: Bool = false) {
|
|
self.key = key
|
|
self.ctrl = ctrl
|
|
self.alt = alt
|
|
self.shift = shift
|
|
}
|
|
|
|
/// Creates a key event from a character.
|
|
public init(character: Character) {
|
|
self.key = .character(character)
|
|
self.ctrl = false
|
|
self.alt = false
|
|
self.shift = character.isUppercase
|
|
}
|
|
}
|
|
|
|
// MARK: - Key
|
|
|
|
/// Represents a keyboard key.
|
|
public enum Key: Hashable, Sendable {
|
|
// Special keys
|
|
case escape
|
|
case enter
|
|
case tab
|
|
case backspace
|
|
case delete
|
|
|
|
// Arrow keys
|
|
case up
|
|
case down
|
|
case left
|
|
case right
|
|
|
|
// Function keys
|
|
case home
|
|
case end
|
|
case pageUp
|
|
case pageDown
|
|
|
|
// Character key
|
|
case character(Character)
|
|
|
|
/// Creates a Key from a character if it's a simple character.
|
|
public static func from(_ char: Character) -> Key {
|
|
.character(char)
|
|
}
|
|
}
|
|
|
|
// MARK: - Key Parsing
|
|
|
|
extension KeyEvent {
|
|
/// Parses raw terminal input into a KeyEvent.
|
|
///
|
|
/// Terminal sends escape sequences for special keys:
|
|
/// - Arrow keys: ESC [ A/B/C/D
|
|
/// - Function keys: ESC [ 1~, ESC [ 2~, etc.
|
|
/// - Ctrl+key: ASCII 1-26
|
|
///
|
|
/// - Parameter bytes: The raw input bytes.
|
|
/// - Returns: The parsed key event, or nil if incomplete.
|
|
public static func parse(_ bytes: [UInt8]) -> KeyEvent? {
|
|
guard !bytes.isEmpty else { return nil }
|
|
|
|
// Single byte
|
|
if bytes.count == 1 {
|
|
return parseSingleByte(bytes[0])
|
|
}
|
|
|
|
// Escape sequence
|
|
if bytes[0] == 0x1B {
|
|
return parseEscapeSequence(bytes)
|
|
}
|
|
|
|
// UTF-8 character
|
|
if let string = String(bytes: bytes, encoding: .utf8),
|
|
let char = string.first {
|
|
return KeyEvent(character: char)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Parses a single byte into a key event.
|
|
private static func parseSingleByte(_ byte: UInt8) -> KeyEvent? {
|
|
switch byte {
|
|
case 0x1B: // Escape
|
|
return KeyEvent(key: .escape)
|
|
case 0x0D, 0x0A: // Enter (CR or LF)
|
|
return KeyEvent(key: .enter)
|
|
case 0x09: // Tab
|
|
return KeyEvent(key: .tab)
|
|
case 0x7F, 0x08: // Backspace (DEL or BS)
|
|
return KeyEvent(key: .backspace)
|
|
case 0x01...0x1A: // Ctrl+A through Ctrl+Z
|
|
let char = Character(UnicodeScalar(byte + 0x60))
|
|
return KeyEvent(key: .character(char), ctrl: true)
|
|
case 0x20...0x7E: // Printable ASCII
|
|
let char = Character(UnicodeScalar(byte))
|
|
return KeyEvent(character: char)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Parses an escape sequence into a key event.
|
|
private static func parseEscapeSequence(_ bytes: [UInt8]) -> KeyEvent? {
|
|
guard bytes.count >= 2 else {
|
|
// Just ESC alone
|
|
return KeyEvent(key: .escape)
|
|
}
|
|
|
|
// CSI sequences: ESC [
|
|
if bytes[1] == 0x5B { // '['
|
|
return parseCSISequence(Array(bytes.dropFirst(2)))
|
|
}
|
|
|
|
// Alt+key: ESC followed by key
|
|
if bytes.count == 2 {
|
|
if let keyEvent = parseSingleByte(bytes[1]) {
|
|
return KeyEvent(key: keyEvent.key, ctrl: keyEvent.ctrl, alt: true, shift: keyEvent.shift)
|
|
}
|
|
}
|
|
|
|
return KeyEvent(key: .escape)
|
|
}
|
|
|
|
/// Parses a CSI (Control Sequence Introducer) sequence.
|
|
private static func parseCSISequence(_ params: [UInt8]) -> KeyEvent? {
|
|
guard !params.isEmpty else { return nil }
|
|
|
|
// Arrow keys: A=up, B=down, C=right, D=left
|
|
switch params.last {
|
|
case 0x41: // 'A'
|
|
return KeyEvent(key: .up)
|
|
case 0x42: // 'B'
|
|
return KeyEvent(key: .down)
|
|
case 0x43: // 'C'
|
|
return KeyEvent(key: .right)
|
|
case 0x44: // 'D'
|
|
return KeyEvent(key: .left)
|
|
case 0x48: // 'H' - Home
|
|
return KeyEvent(key: .home)
|
|
case 0x46: // 'F' - End
|
|
return KeyEvent(key: .end)
|
|
case 0x7E: // '~' - Extended keys
|
|
return parseExtendedKey(params)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Parses extended key sequences (ESC [ n ~).
|
|
private static func parseExtendedKey(_ params: [UInt8]) -> KeyEvent? {
|
|
// Parse the number before '~'
|
|
let numberBytes = params.dropLast()
|
|
guard let string = String(bytes: numberBytes, encoding: .ascii),
|
|
let number = Int(string) else {
|
|
return nil
|
|
}
|
|
|
|
switch number {
|
|
case 1:
|
|
return KeyEvent(key: .home)
|
|
case 2:
|
|
return nil // Insert - not commonly used
|
|
case 3:
|
|
return KeyEvent(key: .delete)
|
|
case 4:
|
|
return KeyEvent(key: .end)
|
|
case 5:
|
|
return KeyEvent(key: .pageUp)
|
|
case 6:
|
|
return KeyEvent(key: .pageDown)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Key Event Handler
|
|
|
|
/// Global key event handler.
|
|
///
|
|
/// Views can register handlers that are called when keys are pressed.
|
|
/// Handlers are processed in reverse order (most recent first).
|
|
public final class KeyEventDispatcher: @unchecked Sendable {
|
|
/// The shared dispatcher instance.
|
|
public static let shared = KeyEventDispatcher()
|
|
|
|
/// Registered key handlers.
|
|
private var handlers: [(KeyEvent) -> Bool] = []
|
|
|
|
private init() {}
|
|
|
|
/// Registers a key handler.
|
|
///
|
|
/// - Parameter handler: A closure that returns true if the key was handled.
|
|
public func addHandler(_ handler: @escaping (KeyEvent) -> Bool) {
|
|
handlers.append(handler)
|
|
}
|
|
|
|
/// Clears all handlers.
|
|
public func clearHandlers() {
|
|
handlers.removeAll()
|
|
}
|
|
|
|
/// Dispatches a key event to handlers.
|
|
///
|
|
/// - Parameter event: The key event to dispatch.
|
|
/// - Returns: True if any handler consumed the event.
|
|
@discardableResult
|
|
public func dispatch(_ event: KeyEvent) -> Bool {
|
|
// Process in reverse order (most recent handlers first)
|
|
for handler in handlers.reversed() {
|
|
if handler(event) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|