diff --git a/Sources/VirtualTerminal/Input/VTEvent.swift b/Sources/VirtualTerminal/Input/VTEvent.swift index 01745fb..af92cc1 100644 --- a/Sources/VirtualTerminal/Input/VTEvent.swift +++ b/Sources/VirtualTerminal/Input/VTEvent.swift @@ -24,9 +24,17 @@ import Geometry /// } /// ``` public enum VTEvent: Equatable, Sendable { + /// A keyboard input event. case key(KeyEvent) + + /// A mouse input event. case mouse(MouseEvent) + + /// A terminal resize event. case resize(ResizeEvent) + + /// A device response event. + case response(VTDeviceAttributesResponse) } // MARK: - Key Event diff --git a/Sources/VirtualTerminal/Input/VTEventStream.swift b/Sources/VirtualTerminal/Input/VTEventStream.swift index 86f6368..94bf96f 100644 --- a/Sources/VirtualTerminal/Input/VTEventStream.swift +++ b/Sources/VirtualTerminal/Input/VTEventStream.swift @@ -25,6 +25,8 @@ /// handleMouseInput(mouse) /// case .resize(let resize): /// handleTerminalResize(resize) +/// case .response(let response): +/// handleTerminalResponse(response) /// } /// } /// ``` diff --git a/Sources/VirtualTerminal/Input/VTInputParser.swift b/Sources/VirtualTerminal/Input/VTInputParser.swift index f52567e..65dca21 100644 --- a/Sources/VirtualTerminal/Input/VTInputParser.swift +++ b/Sources/VirtualTerminal/Input/VTInputParser.swift @@ -72,6 +72,22 @@ internal enum ParseResult { case indeterminate } +/// Device attribute response types based on intermediate characters. +/// +/// Different device attribute queries return different types of information. +/// The intermediate character in the CSI sequence indicates which type of +/// response is being provided. +public enum VTDeviceAttributesResponse: Equatable, Sendable { + /// Primary device attributes (DA1) - basic terminal identification + case primary([Int]) + + /// Secondary device attributes (DA2) - version and capability info + case secondary([Int]) + + /// Tertiary device attributes (DA3) - unit identification + case tertiary([Int]) +} + /// Represents different types of parsed terminal input sequences. /// /// Terminal input consists of various sequence types, from simple characters @@ -86,6 +102,7 @@ internal enum ParseResult { internal enum ParsedSequence { case character(Character) case cursor(direction: Direction, count: Int) + case DeviceAttributes(VTDeviceAttributesResponse) case function(number: Int, modifiers: KeyModifiers) case unknown(sequence: [UInt8]) } @@ -307,6 +324,21 @@ extension VTInputParser { input = input.dropFirst() return parse(next: &input) + case 0x3d: // '=' (DEC Private Mode) + state = .CSI(parameters: parameters, intermediate: intermediate + [byte]) + input = input.dropFirst() + return parse(next: &input) + + case 0x3e: // '>' (DEC Private Mode) + state = .CSI(parameters: parameters, intermediate: intermediate + [byte]) + input = input.dropFirst() + return parse(next: &input) + + case 0x3f: // '?' (DEC Private Mode) + state = .CSI(parameters: parameters, intermediate: intermediate + [byte]) + input = input.dropFirst() + return parse(next: &input) + case 0x40 ... 0x7e: // command state = .normal input = input.dropFirst() @@ -376,7 +408,8 @@ extension VTInputParser { } input = input.dropFirst(2) state = .normal - return .success(.unknown(sequence: [0x1b, 0x50] + data), buffer: input) + return .success(.unknown(sequence: [0x1b, 0x50] + data + [0x1b, byte]), + buffer: input) } input = input.dropFirst() @@ -416,6 +449,12 @@ extension VTInputParser { return .cursor(direction: .right, count: count) case 0x44: // 'D' (CUB) return .cursor(direction: .left, count: count) + case 0x63 where intermediate == [0x3f]: // '\033[?...c' (DA1) + return .DeviceAttributes(.primary(parameters)) + case 0x63 where intermediate == [0x3e]: // '\033[>...c' (DA2) + return .DeviceAttributes(.secondary(parameters)) + case 0x63 where intermediate == [0x3d]: // '\033[=...c' (DA3) + return .DeviceAttributes(.tertiary(parameters)) default: let sequence: [UInt8] = [UInt8(0x1b), UInt8(0x5b)] + parameters.flatMap { String($0).utf8 } + [UInt8(0x3b)] + intermediate + [command] return .unknown(sequence: sequence) @@ -449,37 +488,35 @@ extension ParsedSequence { /// } /// ``` /// - /// - Returns: A `KeyEvent` if the sequence represents keyboard input, - /// `nil` otherwise. - internal var event: KeyEvent? { + /// - Returns: A `VTEvent` if the sequence represents keyboard input, + /// or a terminal response, `nil` otherwise. + internal var event: VTEvent? { return switch self { case let .character(character): if character == "\u{1b}" { - KeyEvent(character: character, keycode: VTKeyCode.escape, modifiers: [], type: .press) + .key(.init(character: character, keycode: VTKeyCode.escape, modifiers: [], type: .press)) } else { - KeyEvent(character: character, keycode: 0, modifiers: [], type: .press) + .key(.init(character: character, keycode: 0, modifiers: [], type: .press)) } case let .cursor(direction, _): switch direction { case .up: - KeyEvent(character: nil, keycode: VTKeyCode.up, modifiers: [], - type: .press) + .key(.init(character: nil, keycode: VTKeyCode.up, modifiers: [], type: .press)) case .down: - KeyEvent(character: nil, keycode: VTKeyCode.down, modifiers: [], - type: .press) + .key(.init(character: nil, keycode: VTKeyCode.down, modifiers: [], type: .press)) case .left: - KeyEvent(character: nil, keycode: VTKeyCode.left, modifiers: [], - type: .press) + .key(.init(character: nil, keycode: VTKeyCode.left, modifiers: [], type: .press)) case .right: - KeyEvent(character: nil, keycode: VTKeyCode.right, modifiers: [], - type: .press) + .key(.init(character: nil, keycode: VTKeyCode.right, modifiers: [], type: .press)) } + case let .DeviceAttributes(attributes): + .response(attributes) + case let .function(number, modifiers): - KeyEvent(character: nil, - keycode: UInt16(Int(VTKeyCode.F1) + number - 1), - modifiers: modifiers, type: .press) + .key(.init(character: nil, keycode: UInt16(Int(VTKeyCode.F1) + number - 1), + modifiers: modifiers, type: .press)) case .unknown(_): nil diff --git a/Sources/VirtualTerminal/Platform/POSIXTerminal.swift b/Sources/VirtualTerminal/Platform/POSIXTerminal.swift index f84a2b9..fc0abcd 100644 --- a/Sources/VirtualTerminal/Platform/POSIXTerminal.swift +++ b/Sources/VirtualTerminal/Platform/POSIXTerminal.swift @@ -221,7 +221,7 @@ internal final actor POSIXTerminal: VTTerminal { return parser.parse(ArraySlice(buffer)) } - return sequences.compactMap { $0.event.map { VTEvent.key($0) } } + return sequences.compactMap(\.event) } continuation.yield(events) } catch { diff --git a/Sources/VirtualTerminal/Rendering/VTRenderer.swift b/Sources/VirtualTerminal/Rendering/VTRenderer.swift index b224e67..e0d3e0c 100644 --- a/Sources/VirtualTerminal/Rendering/VTRenderer.swift +++ b/Sources/VirtualTerminal/Rendering/VTRenderer.swift @@ -134,6 +134,9 @@ public final class VTRenderer: Sendable { /// The underlying platform-specific terminal implementation. private let _terminal: PlatformTerminal + /// The terminal capabilities. + private let capabilities: VTCapabilities + /// The currently displayed buffer state (visible to the user). package nonisolated(unsafe) var front: VTBuffer @@ -159,6 +162,7 @@ public final class VTRenderer: Sendable { /// - Throws: Terminal initialization errors public init(mode: VTMode) async throws { self._terminal = try await PlatformTerminal(mode: mode) + self.capabilities = await VTCapabilities.query(self._terminal) self.front = VTBuffer(size: _terminal.size) self.back = VTBuffer(size: _terminal.size) } diff --git a/Sources/VirtualTerminal/Terminal/VTCapabilities.swift b/Sources/VirtualTerminal/Terminal/VTCapabilities.swift new file mode 100644 index 0000000..211aae8 --- /dev/null +++ b/Sources/VirtualTerminal/Terminal/VTCapabilities.swift @@ -0,0 +1,279 @@ +// Copyright © 2025 Saleem Abdulrasool +// SPDX-License-Identifier: BSD-3-Clause + +/// Terminal types for VT100-style devices. +/// +/// These represent the basic terminal hardware identification returned by +/// legacy VT100 and compatible terminals. +public enum VTTerminalType: UInt8, Sendable { + case vt100 = 1 + case vt101 = 2 + case vt132 = 4 + case vt131 = 5 + case vt102 = 6 + case vt125 = 12 +} + +/// Service class options for VT100-style terminals. +/// +/// These values indicate which hardware options are installed in the +/// terminal, such as graphics processors or printer interfaces. +public enum VTServiceClass: UInt8, Sendable { + case base = 0 // No Options + case stp = 1 // Processor Option (STP) + case avo = 2 // Advanced Video Option (AVO) + case avo_stp = 3 // AVO + STP + case gpo = 4 // Graphics Processor Option (GPO) + case gpo_stp = 5 // GPO + STP + case gpo_avo = 6 // GPO + AVO + case gpo_avo_stp = 7 // GPO + AVO + STP +} + +/// Terminal families for VT220+ style devices. +/// +/// These represent the terminal family identification for newer terminals +/// that support extended feature reporting. +public enum VTTerminalFamily: UInt8, Sendable { + case vt220 = 62 + case vt240 = 18 + case vt320 = 63 + case vt330 = 19 + case vt340 = 24 + case vt420 = 64 + case vt510 = 65 + case vt525 = 28 +} + +/// Individual terminal features that can be reported via Device Attributes. +/// +/// These features correspond to capabilities that VT220+ terminals can +/// report through the Device Attributes response. Each feature represents +/// a specific terminal capability or character set support. +public enum VTDAFeature: UInt8, Sendable { + case ExtendedColumns = 1 // 132 columns + case PrinterPort = 2 + case ReGISGraphics = 3 + case SixelGraphics = 4 + case Katakana = 5 + case SelectiveErase = 6 + case SoftCharacterSet = 7 + case UserDefinedKeys = 8 + case NationalReplacementCharacterSet = 9 + case Kanji = 10 + case StatusDisplay = 11 + case Yugoslavian = 12 + case BlockMode = 13 + case EightBitInterfaceArchitecture = 14 + case TechnicalCharacterSet = 15 + case LocatorPort = 16 + case TerminalStateInterrogation = 17 + case WindowingCapability = 18 + case PrintExtent = 19 + case APL = 20 + case HorizontalScrolling = 21 + case ANSIColor = 22 + case Greek = 23 + case Turkish = 24 + case ArabicBilingualMode1 = 25 + case ArabicBilingualMode2 = 26 + case ArabicBilingualMode3 = 27 + case RectangularAreaOperations = 28 + case ANSITextLocator = 29 + case Hanzi = 30 + case TextMacros = 32 + case HangulHanza = 33 + case Icelandic = 34 + case ArabicBilingualTextControls = 35 + case ArabicBilingualNoTextControls = 36 + case Thai = 37 + case CharacterOutlining = 38 + case PageMemoryExtension = 39 + case ISOLatin2 = 42 + case Ruler = 43 + case PCTerm = 44 + case SoftKeyMapping = 45 + case ASCIIEmulation = 46 + case ClipboardAccess = 52 +} + +/// A set of terminal feature extensions. +/// +/// This type uses a bitmask to efficiently store which terminal features +/// are supported. You can check for specific features using the +/// ``contains(_:)`` method. +/// +/// ## Usage +/// +/// ```swift +/// let extensions = VTExtensions() +/// if extensions.contains(.ANSIColor) { +/// // Terminal supports ANSI colors +/// } +/// ``` +public struct VTExtensions: Sendable, OptionSet { + public typealias RawValue = UInt64 + + public let rawValue: RawValue + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + internal init(_ parameter: VTDAFeature) { + precondition(parameter.rawValue < 64, + "VTExtensions can only support up to 64 features") + self.init(rawValue: 1 << parameter.rawValue) + } + + /// Checks if a specific terminal feature is supported. + /// + /// - Parameter feature: The feature to check for support + /// - Returns: `true` if the feature is supported, `false` otherwise + public func contains(_ feature: VTDAFeature) -> Bool { + precondition(feature.rawValue < 64, + "VTExtensions can only support up to 64 features") + return rawValue & (1 << feature.rawValue) == 0 ? false : true + } +} + +extension VTExtensions { + /// No terminal extensions are supported. + public static var none: VTExtensions { + VTExtensions(rawValue: 0) + } +} + +/// Terminal identity information returned by Device Attributes queries. +/// +/// Terminals can identify themselves in two different ways: +/// - **Specific**: Legacy VT100-style terminals report a specific type +/// and service class +/// - **Compatible**: Modern VT220+ terminals report a family and list +/// of supported features +public enum VTTerminalIdentity: Sendable { + case specific(VTTerminalType, VTServiceClass) + case compatible(VTTerminalFamily, VTExtensions) +} + +/// Terminal capability information. +/// +/// This structure contains information about what a terminal can do, +/// including supported features and terminal identification. Use the +/// ``query(_:timeout:)`` method to detect capabilities from a live terminal. +/// +/// ## Usage +/// +/// ```swift +/// let capabilities = await VTCapabilities.query(terminal) +/// if capabilities.supports(.ANSIColor) { +/// // Use ANSI color codes +/// } +/// if capabilities.supports(.SixelGraphics) { +/// // Terminal can display sixel graphics +/// } +/// ``` +public struct VTCapabilities: Sendable { + public let identity: VTTerminalIdentity + + /// The set of supported terminal features. + /// + /// For legacy terminals that report specific types, this will be empty. + /// For modern terminals, this contains the reported feature set. + public var features: VTExtensions { + return switch identity { + case .specific(_, _): + .none + case let .compatible(_, features): + features + } + } + + /// Checks if the terminal supports a specific feature. + /// + /// - Parameter feature: The feature to check for support + /// - Returns: `true` if the feature is supported, `false` otherwise + public func supports(_ feature: VTDAFeature) -> Bool { + return features.contains(feature) + } +} + +extension VTCapabilities { + /// Queries a terminal for its capabilities. + /// + /// This method sends a Device Attributes (DA1) query to the terminal and + /// waits for the response. It handles both legacy VT100-style responses + /// and modern VT220+ feature lists. + /// + /// The query will timeout if the terminal doesn't respond within the + /// specified duration, returning ``unknown`` capabilities as a fallback. + /// + /// - Parameters: + /// - terminal: The terminal to query for capabilities + /// - timeout: Maximum time to wait for a response + /// - Returns: The detected terminal capabilities + /// + /// ## Usage + /// + /// ```swift + /// let capabilities = await VTCapabilities.query(terminal) + /// print("Terminal supports ANSI color: \(capabilities.supports(.ANSIColor))") + /// ``` + public static func query(_ terminal: some VTTerminal, + timeout: Duration = .milliseconds(250)) async + -> VTCapabilities { + async let capabilities = try? Task.withTimeout(timeout: timeout) { + for try await event in terminal.input { + guard case let .response(response) = event else { continue } + + switch response { + case let .primary(parameters): + // The general format of DA1 response is: + // \u{1b}[;c + // Primary Device Attributes (DA1) has two distinct formats that + // we must handle though. + + // VT100-style: \u{1b}[;c + if parameters.count == 2, + let type = parameters.first, + let type = UInt8(exactly: type), + let type = VTTerminalType(rawValue: type), + let service = parameters.last, + let service = UInt8(exactly: service), + let service = VTServiceClass(rawValue: service) { + return VTCapabilities(identity: .specific(type, service)) + } + + // VT220+style: \u{1b}[;c + if let family = parameters.first, + let family = UInt8(exactly: family), + let family = VTTerminalFamily(rawValue: family) { + let features = parameters.dropFirst() + .compactMap(UInt8.init(exactly:)) + .compactMap(VTDAFeature.init(rawValue:)) + .compactMap(VTExtensions.init) + .reduce(into: VTExtensions()) { $0.formUnion($1) } + return VTCapabilities(identity: .compatible(family, features)) + } + + case .secondary(_), .tertiary(_): + break + } + } + + return .unknown + } + + await terminal <<< .DeviceAttributes(.Request) + return await capabilities ?? .unknown + } +} + +extension VTCapabilities { + /// Default capabilities for unknown or unresponsive terminals. + /// + /// This represents minimal VT101 compatibility with no extended features, + /// suitable as a safe fallback when terminal detection fails. + public static var unknown: VTCapabilities { + VTCapabilities(identity: .specific(.vt101, .base)) + } +}