diff --git a/cli/Sources/Noora/Components/TextPrompt.swift b/cli/Sources/Noora/Components/TextPrompt.swift index 443ec4d9..a154aec5 100644 --- a/cli/Sources/Noora/Components/TextPrompt.swift +++ b/cli/Sources/Noora/Components/TextPrompt.swift @@ -13,6 +13,7 @@ struct TextPrompt { let collapseOnAnswer: Bool let renderer: Rendering let standardPipelines: StandardPipelines + let keyStrokeListener: KeyStrokeListening let logger: Logger? let validationRules: [ValidatableRule] let validator: InputValidating @@ -26,39 +27,44 @@ struct TextPrompt { fatalError("'\(prompt)' can't be prompted in a non-interactive session.") } - var input = "" - - func isReturn(_ character: Character) -> Bool { - #if os(Windows) - return character.unicodeScalars.first?.value == 10 || character.unicodeScalars.first?.value == 13 - #else - return character == "\n" - #endif - } + var input = [Character]() + var cursorIndex = 0 terminal.withoutCursor { - render(input: input, errors: errors) - while let character = terminal.readCharacter(), !isReturn(character) { - #if os(Windows) - // Handle Ctrl+C (character code 3) - // On Windows, Ctrl+C generates character code 3 - // while "getch" is running it doesn't emit a signal - if character.unicodeScalars.first?.value == 3 { - exit(0) + render(input: String(input), cursorIndex: cursorIndex, errors: errors) + keyStrokeListener.listen(terminal: terminal) { keyStroke in + switch keyStroke { + case .returnKey: + return .abort + case let .printable(character): + input.insert(character, at: cursorIndex) + cursorIndex += 1 + case .backspace: + if cursorIndex > 0 { + cursorIndex -= 1 + input.remove(at: cursorIndex) } - - let isBackspace = character.unicodeScalars.first?.value == 8 || character.unicodeScalars.first?.value == 127 - #else - let isBackspace = character == "\u{08}" || character == "\u{7F}" - #endif - if isBackspace { // Handle Backspace (Delete Last Character) - if !input.isEmpty { - input.removeLast() // Remove last character from input + case .delete: + if cursorIndex < input.count { + input.remove(at: cursorIndex) + } + case .leftArrowKey: + if cursorIndex > 0 { + cursorIndex -= 1 } - } else { - input.append(character) + case .rightArrowKey: + if cursorIndex < input.count { + cursorIndex += 1 + } + case .home: + cursorIndex = 0 + case .end: + cursorIndex = input.count + default: + return .continue } - render(input: input) + render(input: String(input), cursorIndex: cursorIndex) + return .continue } } @@ -68,14 +74,14 @@ struct TextPrompt { if input.isEmpty, let defaultValue { resolvedInput = defaultValue } else { - resolvedInput = input + resolvedInput = String(input) } let validationResult = validator.validate(input: resolvedInput, rules: validationRules) switch validationResult { case .success: - render(input: input, withCursor: false) + render(input: String(input), cursorIndex: cursorIndex, withCursor: false) case let .failure(error): return run(errors: error.errors) } @@ -87,7 +93,12 @@ struct TextPrompt { return resolvedInput } - private func render(input: String, withCursor: Bool = true, errors: [ValidatableError] = []) { + private func render( + input: String, + cursorIndex: Int = 0, + withCursor: Bool = true, + errors: [ValidatableError] = [] + ) { let titleOffset = title != nil ? " " : "" var message = "" @@ -96,7 +107,14 @@ struct TextPrompt { .boldIfColoredTerminal(terminal) } - let inputDisplay = "\(input)\(withCursor ? "█" : "")".hexIfColoredTerminal(theme.secondary, terminal) + let inputDisplay: String + if withCursor { + let prefix = String(input.prefix(cursorIndex)) + let suffix = String(input.dropFirst(cursorIndex)) + inputDisplay = "\(prefix)█\(suffix)".hexIfColoredTerminal(theme.secondary, terminal) + } else { + inputDisplay = input.hexIfColoredTerminal(theme.secondary, terminal) + } message += "\(title != nil ? "\n" : "")\(titleOffset)\(prompt.formatted(theme: theme, terminal: terminal)) \(inputDisplay)" diff --git a/cli/Sources/Noora/Noora.swift b/cli/Sources/Noora/Noora.swift index 102072f5..4454c750 100644 --- a/cli/Sources/Noora/Noora.swift +++ b/cli/Sources/Noora/Noora.swift @@ -637,6 +637,7 @@ public final class Noora: Noorable { collapseOnAnswer: collapseOnAnswer, renderer: renderer, standardPipelines: standardPipelines, + keyStrokeListener: keyStrokeListener, logger: logger, validationRules: validationRules, validator: validator diff --git a/cli/Sources/Noora/Utilities/KeyStrokeListener.swift b/cli/Sources/Noora/Utilities/KeyStrokeListener.swift index 001b20fd..5b39a94c 100644 --- a/cli/Sources/Noora/Utilities/KeyStrokeListener.swift +++ b/cli/Sources/Noora/Utilities/KeyStrokeListener.swift @@ -1,7 +1,7 @@ import Foundation /// An enum that represents the key strokes supported by the `KeyStrokeListening` -public enum KeyStroke: Sendable { +public enum KeyStroke: Sendable, Equatable { /// It represents the return key. case returnKey /// It represents a printable character key @@ -52,18 +52,28 @@ public protocol KeyStrokeListening: Sendable { } public struct KeyStrokeListener: KeyStrokeListening { - #if !os(Windows) - private var buffer = "" - #endif + /// Maximum length of a recognised ANSI escape sequence. + /// Sequences longer than this are silently discarded. + private static let maxSequenceLength = 8 public init() {} public func listen(terminal: Terminaling, onKeyPress: @escaping (KeyStroke) -> OnKeyPressResult) { #if !os(Windows) + listenUnix(terminal: terminal, onKeyPress: onKeyPress) + #else + listenWindows(terminal: terminal, onKeyPress: onKeyPress) + #endif + } +} + +#if !os(Windows) + extension KeyStrokeListener { + private func listenUnix(terminal: Terminaling, onKeyPress: @escaping (KeyStroke) -> OnKeyPressResult) { var buffer = "" loop: while let char = terminal.readCharacter() { - // Handle Ctrl+C (character code 3) based on terminal's signal behavior + // Ctrl+C — value 3 (ETX) if char.unicodeScalars.first?.value == 3 { terminal.signalBehavior.restoreCursorIfNeeded() if terminal.signalBehavior == .restoreAndExit { @@ -73,48 +83,129 @@ public struct KeyStrokeListener: KeyStrokeListening { } buffer.append(char) + buffer = readEscapeSequenceIfNeeded(buffer: buffer, terminal: terminal) - // Handle escape sequences - if buffer == "\u{1B}", - let nextChar = terminal.readCharacterNonBlocking() - { - buffer.append(nextChar) - } - - let keyStroke: KeyStroke? = switch (char, buffer) { - case let (char, _) where buffer.count == 1 && char.isPrintable: .printable(char) - case let (char, _) where char == "\n": .returnKey - case (_, "\u{1B}[A"): .upArrowKey - case (_, "\u{1B}[B"): .downArrowKey - case (_, "\u{1B}[C"): .rightArrowKey - case (_, "\u{1B}[D"): .leftArrowKey - case (_, "\u{1B}[5~"): .pageUp - case (_, "\u{1B}[6~"): .pageDown - case (_, "\u{1B}[H"): .home - case (_, "\u{1B}[F"): .end - case ("\u{08}", _): .backspace - case ("\u{7F}", _): .delete - case (_, "\u{1B}"): .escape - default: nil - } - - if let keyStroke { + if let keyStroke = mapToKeyStroke(char: char, buffer: buffer) { buffer = "" switch onKeyPress(keyStroke) { case .abort: break loop case .continue: continue } } - if buffer.count > 3 { + + // Discard unrecognised sequences to prevent buffer growth. + if buffer.count > KeyStrokeListener.maxSequenceLength { + #if DEBUG + fputs("KeyStrokeListener: unrecognized sequence: \(buffer.debugDescription)\n", stderr) + #endif buffer = "" } } - #else + } + + /// Reads additional characters from the terminal to complete an ANSI escape sequence, + /// returning the updated buffer. If the buffer doesn't start with ESC, the buffer is + /// returned unchanged. + /// + /// Supported sequence shapes: + /// - `ESC` — standalone Escape key + /// - `ESC [ ` — cursor keys: A B C D H F + /// - `ESC [ ~` — special keys: 3~ delete, 5~ pageUp, 6~ pageDown + /// + /// The reader is intentionally generic: it keeps consuming non-blocking characters while + /// the sequence looks incomplete, stopping as soon as `isCompleteEscapeSequence` returns + /// `true` or the safety cap is reached. + private func readEscapeSequenceIfNeeded(buffer: String, terminal: Terminaling) -> String { + guard buffer == "\u{1B}" else { return buffer } + + var result = buffer + while let next = terminal.readCharacterNonBlocking() { + result.append(next) + if isCompleteEscapeSequence(result) { break } + if result.count >= KeyStrokeListener.maxSequenceLength { break } + } + return result + } + + /// Returns `true` when `buffer` represents a complete, self-contained escape sequence. + /// + /// Rules: + /// - A lone ESC with nothing else readable is complete. + /// - `ESC [` followed by a letter terminates a CSI sequence (cursor/navigation keys). + /// - `ESC [` followed by digits and `~` terminates a VT-style special key sequence. + private func isCompleteEscapeSequence(_ buffer: String) -> Bool { + guard buffer.hasPrefix("\u{1B}") else { return true } + guard buffer.count >= 2 else { return false } + + guard buffer.hasPrefix("\u{1B}[") else { return buffer.count >= 2 } + + guard let last = buffer.last else { return false } + + if last.isLetter { return true } + + if last == "~" { return true } + + return false + } + + /// Maps a raw `(char, buffer)` pair to a `KeyStroke`, or returns `nil` for + /// unrecognised input. + /// + /// Single-character input is matched on `char`; multi-character escape sequences + /// are matched on the accumulated `buffer`. + private func mapToKeyStroke(char: Character, buffer: String) -> KeyStroke? { + // --- Single character --- + if buffer.count == 1 { + switch char { + case "\n": return .returnKey + case "\u{08}": return .backspace // BS (^H) — some terminals + case "\u{7F}": return .backspace // DEL — macOS/Linux Backspace key + case "\u{1B}": return .escape + default: + return char.isPrintable ? .printable(char) : nil + } + } + + switch buffer { + case "\u{1B}[A": return .upArrowKey + case "\u{1B}[B": return .downArrowKey + case "\u{1B}[C": return .rightArrowKey + case "\u{1B}[D": return .leftArrowKey + case "\u{1B}[3~": return .delete + case "\u{1B}[5~": return .pageUp + case "\u{1B}[6~": return .pageDown + case "\u{1B}[H": return .home + case "\u{1B}[F": return .end + default: return nil + } + } + } +#endif + +// MARK: - Windows + +#if os(Windows) + extension KeyStrokeListener { + private enum WindowsKeyCode { + static let ctrlC: UInt8 = 3 + static let backspace: UInt8 = 8 + static let lineFeed: UInt8 = 10 + static let carriageReturn: UInt8 = 13 + static let escape: UInt8 = 1 + static let home: UInt8 = 71 + static let upArrow: UInt8 = 72 + static let pageUp: UInt8 = 73 + static let leftArrow: UInt8 = 75 + static let rightArrow: UInt8 = 77 + static let end: UInt8 = 79 + static let downArrow: UInt8 = 80 + static let pageDown: UInt8 = 81 + static let delete: UInt8 = 83 + } + + private func listenWindows(terminal: Terminaling, onKeyPress: @escaping (KeyStroke) -> OnKeyPressResult) { loop: while let char = terminal.readRawCharacter() { - // Handle Ctrl+C (character code 3) based on terminal's signal behavior - // On Windows, Ctrl+C generates character code 3 - // while "getch" is running it doesn't emit a signal - if char == 3 { + if char == WindowsKeyCode.ctrlC { terminal.signalBehavior.restoreCursorIfNeeded() if terminal.signalBehavior == .restoreAndExit { exit(0) @@ -122,41 +213,39 @@ public struct KeyStrokeListener: KeyStrokeListening { break loop } - let keyStroke: KeyStroke? + guard let keyStroke = mapWindowsKeyCode(char) else { continue } - switch char { - case 1: keyStroke = .escape - case 10, 13: keyStroke = .returnKey // Handle both LF (10) and CR (13) for Windows - case 8, 14: keyStroke = .backspace // Handle both BS (8) and SO (14) - case 71: keyStroke = .home - case 72: keyStroke = .upArrowKey - case 73: keyStroke = .pageUp - case 75: keyStroke = .leftArrowKey - case 77: keyStroke = .rightArrowKey - case 79: keyStroke = .end - case 80: keyStroke = .downArrowKey - case 81: keyStroke = .pageDown - case 83: keyStroke = .delete - default: - if let scalar = UnicodeScalar(UInt32(char)), - Character(scalar).isPrintable - { - keyStroke = .printable(Character(scalar)) - } else { - keyStroke = nil - } + switch onKeyPress(keyStroke) { + case .abort: break loop + case .continue: continue } + } + } - if let keyStroke { - switch onKeyPress(keyStroke) { - case .abort: break loop - case .continue: continue - } - } + private func mapWindowsKeyCode(_ code: UInt8) -> KeyStroke? { + switch code { + case WindowsKeyCode.escape: return .escape + case WindowsKeyCode.lineFeed, + WindowsKeyCode.carriageReturn: return .returnKey + case WindowsKeyCode.backspace: return .backspace + case WindowsKeyCode.home: return .home + case WindowsKeyCode.upArrow: return .upArrowKey + case WindowsKeyCode.pageUp: return .pageUp + case WindowsKeyCode.leftArrow: return .leftArrowKey + case WindowsKeyCode.rightArrow: return .rightArrowKey + case WindowsKeyCode.end: return .end + case WindowsKeyCode.downArrow: return .downArrowKey + case WindowsKeyCode.pageDown: return .pageDown + case WindowsKeyCode.delete: return .delete + default: + guard let scalar = UnicodeScalar(UInt32(code)), + Character(scalar).isPrintable + else { return nil } + return .printable(Character(scalar)) } - #endif + } } -} +#endif extension KeyStrokeListening { /// Listens for key-strokes notifying the caller by calling the given closure. diff --git a/cli/Tests/NooraTests/Components/TextPromptTests.swift b/cli/Tests/NooraTests/Components/TextPromptTests.swift index 807a4964..c44fe2e7 100644 --- a/cli/Tests/NooraTests/Components/TextPromptTests.swift +++ b/cli/Tests/NooraTests/Components/TextPromptTests.swift @@ -4,6 +4,7 @@ import Testing struct TextPromptTests { let renderer = MockRenderer() let terminal = MockTerminal(isColored: false) + let keyStrokeListener = MockKeyStrokeListener() let validator = MockValidator() @Test func renders_the_right_output() { @@ -19,11 +20,23 @@ struct TextPromptTests { collapseOnAnswer: true, renderer: renderer, standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener, logger: nil, validationRules: [], validator: validator ) - terminal.characters = ["M", "y", "A", "p", "p", "\u{08}", "p", "\n"] + keyStrokeListener.keyPressStub.withValue { + $0 = [ + .printable("M"), + .printable("y"), + .printable("A"), + .printable("p"), + .printable("p"), + .backspace, + .printable("p"), + .returnKey, + ] + } // When let result = subject.run() @@ -95,11 +108,23 @@ struct TextPromptTests { collapseOnAnswer: true, renderer: renderer, standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener, logger: nil, validationRules: [], validator: validator ) - terminal.characters = ["M", "y", "A", "p", "p", "\u{08}", "p", "\n"] + keyStrokeListener.keyPressStub.withValue { + $0 = [ + .printable("M"), + .printable("y"), + .printable("A"), + .printable("p"), + .printable("p"), + .backspace, + .printable("p"), + .returnKey, + ] + } // When let result = subject.run() @@ -162,11 +187,14 @@ struct TextPromptTests { collapseOnAnswer: true, renderer: renderer, standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener, logger: nil, validationRules: [], validator: validator ) - terminal.characters = ["\n"] + keyStrokeListener.keyPressStub.withValue { + $0 = [.returnKey] + } // When let result = subject.run() @@ -201,11 +229,14 @@ struct TextPromptTests { collapseOnAnswer: true, renderer: renderer, standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener, logger: nil, validationRules: [], validator: validator ) - terminal.characters = ["F", "o", "o", "\n"] + keyStrokeListener.keyPressStub.withValue { + $0 = [.printable("F"), .printable("o"), .printable("o"), .returnKey] + } // When let result = subject.run() @@ -213,4 +244,67 @@ struct TextPromptTests { // Then #expect(result == "Foo") } + + @Test func supports_cursor_movement() { + // Given + let subject = TextPrompt( + title: "Name", + prompt: "How would you like to name the project?", + description: nil, + defaultValue: nil, + theme: .test(), + content: .default, + terminal: terminal, + collapseOnAnswer: true, + renderer: renderer, + standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener, + logger: nil, + validationRules: [], + validator: validator + ) + keyStrokeListener.keyPressStub.withValue { + $0 = [ + .printable("A"), + .printable("C"), + .leftArrowKey, + .printable("B"), + .rightArrowKey, + .printable("D"), + .returnKey, + ] + } + + // When + let result = subject.run() + + // Then + #expect(result == "ABCD") + var renders = Array(renderer.renders.reversed()) + + // Initial empty state + #expect(renders.popLast() == "Name\n How would you like to name the project? █") + + // After 'A' + #expect(renders.popLast() == "Name\n How would you like to name the project? A█") + + // After 'C' + #expect(renders.popLast() == "Name\n How would you like to name the project? AC█") + + // After Left Arrow (cursor is at 'C') + #expect(renders.popLast() == "Name\n How would you like to name the project? A█C") + + // After 'B' (inserted before 'C') + #expect(renders.popLast() == "Name\n How would you like to name the project? AB█C") + + // After Right Arrow (cursor is at end) + #expect(renders.popLast() == "Name\n How would you like to name the project? ABC█") + + // After 'D' + #expect(renders.popLast() == "Name\n How would you like to name the project? ABCD█") + + // Final states + #expect(renders.popLast() == "Name\n How would you like to name the project? ABCD") + #expect(renders.popLast() == "✔︎ Name: ABCD ") + } } diff --git a/cli/Tests/NooraTests/Mocks/MockTerminal.swift b/cli/Tests/NooraTests/Mocks/MockTerminal.swift index f96ed182..0075c324 100644 --- a/cli/Tests/NooraTests/Mocks/MockTerminal.swift +++ b/cli/Tests/NooraTests/Mocks/MockTerminal.swift @@ -52,11 +52,11 @@ final class MockTerminal: Terminaling, @unchecked Sendable { } func readRawCharacterNonBlocking() -> Int32? { - nil + readRawCharacter() } func readCharacterNonBlocking() -> Character? { - nil + readCharacter() } func size() -> TerminalSize? { diff --git a/cli/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift b/cli/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift new file mode 100644 index 00000000..2c0f107f --- /dev/null +++ b/cli/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift @@ -0,0 +1,62 @@ +import Testing +@testable import Noora + +struct KeyStrokeListenerTests { + @Test func maps_7F_to_backspace_on_non_windows() { + #if !os(Windows) + // Given + let terminal = MockTerminal() + terminal.characters = ["\u{7F}", "\n"] + let listener = KeyStrokeListener() + var capturedKeyStrokes = [KeyStroke]() + + // When + listener.listen(terminal: terminal) { keyStroke in + capturedKeyStrokes.append(keyStroke) + return keyStroke == .returnKey ? .abort : .continue + } + + // Then + #expect(capturedKeyStrokes == [.backspace, .returnKey]) + #endif + } + + @Test func maps_08_to_backspace_on_non_windows() { + #if !os(Windows) + // Given + let terminal = MockTerminal() + terminal.characters = ["\u{08}", "\n"] + let listener = KeyStrokeListener() + var capturedKeyStrokes = [KeyStroke]() + + // When + listener.listen(terminal: terminal) { keyStroke in + capturedKeyStrokes.append(keyStroke) + return keyStroke == .returnKey ? .abort : .continue + } + + // Then + #expect(capturedKeyStrokes == [.backspace, .returnKey]) + #endif + } + + @Test func maps_ESC_3_tilde_to_delete_on_non_windows() { + #if !os(Windows) + // Given + let terminal = MockTerminal() + // ESC [ 3 ~ + terminal.characters = ["\u{1B}", "[", "3", "~", "\n"] + let listener = KeyStrokeListener() + var capturedKeyStrokes = [KeyStroke]() + + // When + listener.listen(terminal: terminal) { keyStroke in + capturedKeyStrokes.append(keyStroke) + return keyStroke == .returnKey ? .abort : .continue + } + + // Then + #expect(capturedKeyStrokes == [.delete, .returnKey]) + #endif + } +}