Files
TUIkit/Sources/TUIKit/Core/KeyEvent.swift
T
phranck 45e73faafb refactor: Rename package from SwiftTUI to TUIKit
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.
2026-01-28 19:32:09 +01:00

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
}
}