diff --git a/CHANGELOG.md b/CHANGELOG.md index b75c8dac..62e24979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.28.1] - 2025-02-27 +### Details +#### Fix +- Add .returnKey again to KeystrokeListener + +## [0.28.0] - 2025-02-27 +### Details +#### Feat +- Add autoSelectSingle option to SingleChoicePrompt + +## [0.27.0] - 2025-02-27 +### Details +#### Feat +- Allow custom renderer and keystroke listener in Noora + +## [0.26.2] - 2025-02-27 +### Details +#### Chore +- Update dependency pnpm to v10.5.2 + +## [0.26.1] - 2025-02-27 +### Details +#### Fix +- Add filterMode parameter to Noorable singleChoicePrompt methods + ## [0.26.0] - 2025-02-26 ### Details #### Feat @@ -532,6 +557,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update dependency reddavis/asynchrone to from: "0.22.0" - Rename the project +[0.28.1]: https://github.com/tuist/XcodeGraph/compare/0.28.0..0.28.1 +[0.28.0]: https://github.com/tuist/XcodeGraph/compare/0.27.0..0.28.0 +[0.27.0]: https://github.com/tuist/XcodeGraph/compare/0.26.2..0.27.0 +[0.26.2]: https://github.com/tuist/XcodeGraph/compare/0.26.1..0.26.2 +[0.26.1]: https://github.com/tuist/XcodeGraph/compare/0.26.0..0.26.1 [0.26.0]: https://github.com/tuist/XcodeGraph/compare/0.25.0..0.26.0 [0.25.0]: https://github.com/tuist/XcodeGraph/compare/0.24.0..0.25.0 [0.24.0]: https://github.com/tuist/XcodeGraph/compare/0.23.1..0.24.0 diff --git a/Sources/Noora/Components/SingleChoicePrompt.swift b/Sources/Noora/Components/SingleChoicePrompt.swift index 3f0851ab..e11f50a9 100644 --- a/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/Sources/Noora/Components/SingleChoicePrompt.swift @@ -20,6 +20,7 @@ struct SingleChoicePrompt { let terminal: Terminaling let collapseOnSelection: Bool let filterMode: SingleChoicePromptFilterMode + let autoselectSingleChoice: Bool let renderer: Rendering let standardPipelines: StandardPipelines let keyStrokeListener: KeyStrokeListening @@ -35,6 +36,11 @@ struct SingleChoicePrompt { // MARK: - Private private func run(options: [(T, String)]) -> T { + if autoselectSingleChoice, options.count == 1 { + renderResult(selectedOption: options[0]) + return options[0].0 + } + if !terminal.isInteractive { fatalError("'\(question)' can't be prompted in a non-interactive session.") } diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 6d8fa1a5..60a1c0df 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -67,13 +67,18 @@ public protocol Noorable { /// - options: The options to show to the user. /// - description: Use it to add some explanation to what the question is for. /// - collapseOnSelection: Whether the prompt should collapse after the user selects an option. + /// - filterMode: Whether filtering should be disabled, toggleable, or enabled. + /// - autoselectSingleChoice: Whether the prompt should automatically select the first item when options only contains one + /// item. /// - Returns: The option selected by the user. func singleChoicePrompt( title: TerminalText?, question: TerminalText, options: [T], description: TerminalText?, - collapseOnSelection: Bool + collapseOnSelection: Bool, + filterMode: SingleChoicePromptFilterMode, + autoselectSingleChoice: Bool ) -> T /// It shows multiple options to the user to select one. @@ -82,12 +87,17 @@ public protocol Noorable { /// - question: The quetion to ask to the user. /// - description: Use it to add some explanation to what the question is for. /// - collapseOnSelection: Whether the prompt should collapse after the user selects an option. + /// - filterMode: Whether filtering should be disabled, toggleable, or enabled. + /// - autoselectSingleChoice: Whether the prompt should automatically select the first item when options only contains one + /// item. /// - Returns: The option selected by the user. func singleChoicePrompt( title: TerminalText?, question: TerminalText, description: TerminalText?, - collapseOnSelection: Bool + collapseOnSelection: Bool, + filterMode: SingleChoicePromptFilterMode, + autoselectSingleChoice: Bool ) -> T /// It shows a component to answer yes or no to a question. @@ -182,15 +192,21 @@ public class Noora: Noorable { let standardPipelines: StandardPipelines let theme: Theme let terminal: Terminaling + let renderer: Rendering + let keyStrokeListener: KeyStrokeListening public init( theme: Theme = .default, terminal: Terminaling = Terminal(), - standardPipelines: StandardPipelines = StandardPipelines() + standardPipelines: StandardPipelines = StandardPipelines(), + renderer: Rendering = Renderer(), + keyStrokeListener: KeyStrokeListening = KeyStrokeListener() ) { self.theme = theme self.terminal = terminal self.standardPipelines = standardPipelines + self.renderer = renderer + self.keyStrokeListener = keyStrokeListener } public func singleChoicePrompt( @@ -199,7 +215,8 @@ public class Noora: Noorable { options: [T], description: TerminalText?, collapseOnSelection: Bool, - filterMode: SingleChoicePromptFilterMode + filterMode: SingleChoicePromptFilterMode, + autoselectSingleChoice: Bool ) -> T where T: CustomStringConvertible, T: Equatable { let component = SingleChoicePrompt( title: title, @@ -209,9 +226,10 @@ public class Noora: Noorable { terminal: terminal, collapseOnSelection: collapseOnSelection, filterMode: filterMode, - renderer: Renderer(), - standardPipelines: StandardPipelines(), - keyStrokeListener: KeyStrokeListener() + autoselectSingleChoice: autoselectSingleChoice, + renderer: renderer, + standardPipelines: standardPipelines, + keyStrokeListener: keyStrokeListener ) return component.run(options: options) } @@ -221,7 +239,8 @@ public class Noora: Noorable { question: TerminalText, description: TerminalText? = nil, collapseOnSelection: Bool = true, - filterMode: SingleChoicePromptFilterMode = .disabled + filterMode: SingleChoicePromptFilterMode = .disabled, + autoselectSingleChoice: Bool = true ) -> T { let component = SingleChoicePrompt( title: title, @@ -231,9 +250,10 @@ public class Noora: Noorable { terminal: terminal, collapseOnSelection: collapseOnSelection, filterMode: filterMode, - renderer: Renderer(), + autoselectSingleChoice: autoselectSingleChoice, + renderer: renderer, standardPipelines: standardPipelines, - keyStrokeListener: KeyStrokeListener() + keyStrokeListener: keyStrokeListener ) return component.run() } @@ -251,8 +271,8 @@ public class Noora: Noorable { theme: theme, terminal: terminal, collapseOnAnswer: collapseOnAnswer, - renderer: Renderer(), - standardPipelines: StandardPipelines() + renderer: renderer, + standardPipelines: standardPipelines ) return component.run() } @@ -271,9 +291,9 @@ public class Noora: Noorable { theme: theme, terminal: terminal, collapseOnSelection: collapseOnSelection, - renderer: Renderer(), + renderer: renderer, standardPipelines: standardPipelines, - keyStrokeListener: KeyStrokeListener(), + keyStrokeListener: keyStrokeListener, defaultAnswer: defaultAnswer ).run() } @@ -324,7 +344,7 @@ public class Noora: Noorable { task: task, theme: theme, terminal: terminal, - renderer: Renderer(), + renderer: renderer, standardPipelines: standardPipelines ) try await progressStep.run() @@ -345,8 +365,8 @@ public class Noora: Noorable { task: task, theme: theme, terminal: terminal, - renderer: Renderer(), - standardPipelines: StandardPipelines() + renderer: renderer, + standardPipelines: standardPipelines ).run() } @@ -361,14 +381,18 @@ extension Noorable { question: TerminalText, options: [T], description: TerminalText? = nil, - collapseOnSelection: Bool = true + collapseOnSelection: Bool = true, + filterMode: SingleChoicePromptFilterMode = .disabled, + autoselectSingleChoice: Bool = true ) -> T { singleChoicePrompt( title: title, question: question, options: options, description: description, - collapseOnSelection: collapseOnSelection + collapseOnSelection: collapseOnSelection, + filterMode: filterMode, + autoselectSingleChoice: autoselectSingleChoice ) } @@ -376,13 +400,17 @@ extension Noorable { title: TerminalText? = nil, question: TerminalText, description: TerminalText? = nil, - collapseOnSelection: Bool = true + collapseOnSelection: Bool = true, + filterMode: SingleChoicePromptFilterMode = .disabled, + autoselectSingleChoice: Bool = true ) -> T { singleChoicePrompt( title: title, question: question, description: description, - collapseOnSelection: collapseOnSelection + collapseOnSelection: collapseOnSelection, + filterMode: filterMode, + autoselectSingleChoice: autoselectSingleChoice ) } diff --git a/Sources/Noora/Utilities/KeyStrokeListener.swift b/Sources/Noora/Utilities/KeyStrokeListener.swift index 85def7d7..44b83369 100644 --- a/Sources/Noora/Utilities/KeyStrokeListener.swift +++ b/Sources/Noora/Utilities/KeyStrokeListener.swift @@ -1,7 +1,9 @@ import Foundation /// An enum that represents the key strokes supported by the `KeyStrokeListening` -public enum KeyStroke { +public enum KeyStroke: Equatable { + /// It represents the return key. + case returnKey /// It represents a printable character key case printable(Character) /// It represents the up arrow @@ -18,6 +20,20 @@ public enum KeyStroke { case delete /// It represents the escape key. case escape + /// It represents a left mouse button press. + case leftMouseDown(position: TerminalPosition) + /// It represents a right mouse button press. + case rightMouseDown(position: TerminalPosition) + /// It represents a left mouse button release. + case leftMouseUp(position: TerminalPosition) + /// It represents a right mouse button release. + case rightMouseUp(position: TerminalPosition) + /// It represents dragging with left mouse button. + case leftMouseDrag(position: TerminalPosition) + /// It represents dragging with right mouse button. + case rightMouseDrag(position: TerminalPosition) + /// It represents mouse movement without any buttons pressed. + case mouseMoved(position: TerminalPosition) } /// A result that the caller can use in the onKeyPress callback to instruct the listener on how to @@ -61,6 +77,7 @@ public struct KeyStrokeListener: KeyStrokeListening { 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 @@ -68,6 +85,7 @@ public struct KeyStrokeListener: KeyStrokeListening { case ("\u{08}", _): .backspace case ("\u{7F}", _): .delete case (_, "\u{1B}"): .escape + case let (_, buf) where buf.hasPrefix("\u{1B}[<"): decodeSGRMouseEvent(from: buf) default: nil } @@ -78,11 +96,41 @@ public struct KeyStrokeListener: KeyStrokeListening { case .continue: continue } } - if buffer.count > 3 { + if buffer.count > 14 { buffer = "" } } } + + /// Decodes a mouse event in SGR format (ESC[ KeyStroke? { + guard let endIndex = buffer.firstIndex(where: { $0 == "M" || $0 == "m" }) else { + return nil + } + + let isPress = buffer[endIndex] == "M" + let parts = String(buffer.dropFirst(3).prefix(while: { $0 != "M" && $0 != "m" })) + .split(separator: ";") + .compactMap { Int($0) } + + guard parts.count == 3 else { + return nil + } + + let button = parts[0] + let position = TerminalPosition(row: parts[2], column: parts[1]) + + return switch (button, isPress) { + case (0, true): .leftMouseDown(position: position) + case (0, false): .leftMouseUp(position: position) + case (2, true): .rightMouseDown(position: position) + case (2, false): .rightMouseUp(position: position) + case (32, _): .leftMouseDrag(position: position) + case (34, _): .rightMouseDrag(position: position) + case (35, _): .mouseMoved(position: position) + default: nil + } + } } extension KeyStrokeListening { diff --git a/Sources/Noora/Utilities/Renderer.swift b/Sources/Noora/Utilities/Renderer.swift index 5d7b5305..941e588b 100644 --- a/Sources/Noora/Utilities/Renderer.swift +++ b/Sources/Noora/Utilities/Renderer.swift @@ -17,7 +17,7 @@ public protocol Rendering: AnyObject { public class Renderer: Rendering { private var lastRenderedContent: [String] = [] - init() {} + public init() {} private func eraseLines(_ lines: Int, standardPipeline: StandardPipelining) { if lines == 0 { return } diff --git a/Sources/Noora/Utilities/Terminal.swift b/Sources/Noora/Utilities/Terminal.swift index 9dfc39d4..7c6cd04e 100644 --- a/Sources/Noora/Utilities/Terminal.swift +++ b/Sources/Noora/Utilities/Terminal.swift @@ -11,14 +11,33 @@ public protocol Terminaling { var isColored: Bool { get } func withoutCursor(_ body: () throws -> Void) rethrows func inRawMode(_ body: @escaping () throws -> Void) rethrows + func withMouseTracking( + trackMotion: Bool, + _ body: () throws -> Void + ) rethrows func readCharacter() -> Character? func readCharacterNonBlocking() -> Character? func size() -> TerminalSize? } -public struct TerminalSize { - let rows: Int - let columns: Int +public struct TerminalPosition: Equatable { + public let row: Int + public let column: Int + + public init(row: Int, column: Int) { + self.row = row + self.column = column + } +} + +public struct TerminalSize: Equatable { + public let rows: Int + public let columns: Int + + public init(rows: Int, columns: Int) { + self.rows = rows + self.columns = columns + } } public struct Terminal: Terminaling { @@ -30,7 +49,10 @@ public struct Terminal: Terminaling { self.isColored = isColored for signalType in [SIGINT, SIGTERM, SIGQUIT, SIGHUP] { signal(signalType) { _ in + // Ensure cursor is visible print("\u{1B}[?25h", terminator: "") + // Disable mouse tracking + print("\u{1B}[?1003l\u{1B}[?1006l\u{1B}[?1000l", terminator: "") fflush(stdout) exit(0) } @@ -57,6 +79,43 @@ public struct Terminal: Terminaling { fflush(stdout) } + /// Runs a block of code with mouse tracking enabled, disabling it afterwards. + /// - Parameter trackMotion: Whether or not to track drag and hover events in addition to clicks. + /// - Parameter body: The closure to execute with mouse tracking enabled. + public func withMouseTracking( + trackMotion: Bool = false, + _ body: () throws -> Void + ) rethrows { + enableMouseTracking(trackMotion: trackMotion) + defer { disableMouseTracking() } + try body() + } + + /// Enables mouse tracking in the terminal. + /// - Parameter trackMotion: Whether or not to track drag and hover events in addition to clicks. + public func enableMouseTracking(trackMotion: Bool = false) { + // Enable SGR mouse mode for better event reporting + print("\u{1B}[?1006h", terminator: "") + // Enable basic mouse tracking (clicks) + print("\u{1B}[?1000h", terminator: "") + if trackMotion { + // Enable motion tracking + print("\u{1B}[?1003h", terminator: "") + } + fflush(stdout) + } + + /// Disables mouse tracking in the terminal. + public func disableMouseTracking() { + // Disable motion tracking + print("\u{1B}[?1003l", terminator: "") + // Disable SGR mouse mode + print("\u{1B}[?1006l", terminator: "") + // Disable basic mouse tracking + print("\u{1B}[?1000l", terminator: "") + fflush(stdout) + } + /// Enables raw mode for the terminal and restores the mode after the body is executed. /// - Parameter body: The body to execute with raw mode enabled. public func inRawMode(_ body: () throws -> Void) rethrows { diff --git a/Sources/examples-cli/Commands/MouseTrackingCommand.swift b/Sources/examples-cli/Commands/MouseTrackingCommand.swift new file mode 100644 index 00000000..d9af98ba --- /dev/null +++ b/Sources/examples-cli/Commands/MouseTrackingCommand.swift @@ -0,0 +1,265 @@ +import ArgumentParser +import Foundation +import Noora +import Rainbow + +struct MouseTrackingCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mouse-tracking", + abstract: "A command that showcases mouse tracking in the terminal." + ) + + mutating func run() async throws { + let terminal = Terminal() + let keyStrokeListener = KeyStrokeListener() + var canvas: [String: (char: String, color: (String) -> String)] = [:] + var isDragging = false + var currentBrushIndex = 0 + var lastPosition: TerminalPosition? + var lastTerminalSize: TerminalSize? + + let brushes: [(char: String, color: (String) -> String)] = [ + ("●", { $0.red }), + ("■", { $0.yellow }), + ("★", { $0.green }), + ("♦", { $0.blue }), + ("♥", { $0.magenta }), + ("⌫", { $0 }), + ] + + print("\u{1B}[2J", terminator: "") // Clear screen + print("\u{1B}[H", terminator: "") // Move cursor to home + terminal.hideCursor() + defer { + terminal.showCursor() + print("\u{1B}[2J\u{1B}[H") // Clear screen + } + + terminal.inRawMode { + terminal.withMouseTracking(trackMotion: true) { + drawScreen(terminal: terminal, brushes: brushes, currentBrushIndex: currentBrushIndex, canvas: canvas) + lastTerminalSize = terminal.size() + + keyStrokeListener.listen(terminal: terminal) { [self] keyStroke in + let currentSize = terminal.size() + if currentSize != lastTerminalSize { + drawScreen(terminal: terminal, brushes: brushes, currentBrushIndex: currentBrushIndex, canvas: canvas) + lastTerminalSize = currentSize + } + + switch keyStroke { + case let .printable(character) where character == "q": + return .abort + case let .printable(character) where character == "c": + for (key, _) in canvas { + let parts = key.split(separator: ";").compactMap { Int($0) } + if parts.count == 2 { + drawPoint(TerminalPosition(row: parts[0], column: parts[1]), nil, terminal: terminal) + } + } + canvas.removeAll() + return .continue + case let .leftMouseDown(position): + isDragging = true + lastPosition = nil + draw( + at: position, + lastPosition: &lastPosition, + canvas: &canvas, + brushes: brushes, + currentBrushIndex: currentBrushIndex, + terminal: terminal + ) + return .continue + case .leftMouseUp: + isDragging = false + lastPosition = nil + return .continue + case let .leftMouseDrag(position): + if isDragging { + draw( + at: position, + lastPosition: &lastPosition, + canvas: &canvas, + brushes: brushes, + currentBrushIndex: currentBrushIndex, + terminal: terminal + ) + } + return .continue + case .rightMouseDown: + currentBrushIndex = (currentBrushIndex + 1) % brushes.count + updateBrushDisplay(brushes: brushes, currentBrushIndex: currentBrushIndex) + return .continue + default: + return .continue + } + } + } + } + } + + private func interpolatePoints(from: TerminalPosition, to: TerminalPosition) -> [TerminalPosition] { + let dx = to.column - from.column + let dy = to.row - from.row + let steps = max(abs(dx), abs(dy)) + + guard steps > 0 else { return [from] } + + var points: [TerminalPosition] = [] + for i in 0 ... steps { + let t = Double(i) / Double(steps) + let x = from.column + Int(round(Double(dx) * t)) + let y = from.row + Int(round(Double(dy) * t)) + points.append(TerminalPosition(row: y, column: x)) + } + return points + } + + private func positionKey(_ pos: TerminalPosition) -> String { + "\(pos.row);\(pos.column)" + } + + private func drawPoint( + _ point: TerminalPosition, + _ content: (char: String, color: (String) -> String)?, + terminal: Terminal + ) { + let canvasTop = 5 + let terminalSize = terminal.size() + let canvasWidth = (terminalSize?.columns ?? 40) - 2 + let canvasHeight = (terminalSize?.rows ?? 120) - canvasTop + + if point.row > canvasTop, + point.row < canvasTop + canvasHeight, + point.column > 1, + point.column < canvasWidth + 1 + { + print("\u{1B}[\(point.row);\(point.column)H", terminator: "") + if let content { + if content.char == "⌫" { + print(" ", terminator: "") + } else { + print(content.color(content.char), terminator: "") + } + } else { + print(" ", terminator: "") + } + fflush(stdout) + } + } + + private func updateBrushDisplay( + brushes: [(char: String, color: (String) -> String)], + currentBrushIndex: Int + ) { + let brush = brushes[currentBrushIndex] + print("\u{1B}[4;1H Current brush: \(brush.color(brush.char)) ", terminator: "") + fflush(stdout) + } + + private func draw( + at position: TerminalPosition, + lastPosition: inout TerminalPosition?, + canvas: inout [String: (char: String, color: (String) -> String)], + brushes: [(char: String, color: (String) -> String)], + currentBrushIndex: Int, + terminal: Terminal + ) { + let canvasTop = 5 + let terminalSize = terminal.size() + let canvasWidth = (terminalSize?.columns ?? 40) - 2 + let canvasHeight = (terminalSize?.rows ?? 120) - canvasTop + + func isInCanvas(_ pos: TerminalPosition) -> Bool { + pos.row > canvasTop && + pos.row < canvasTop + canvasHeight && + pos.column > 1 && + pos.column < canvasWidth + 1 + } + + if let last = lastPosition { + let points = interpolatePoints(from: last, to: position) + for point in points { + if isInCanvas(point) { + let key = positionKey(point) + if currentBrushIndex == brushes.count - 1 { + if canvas[key] != nil { + canvas.removeValue(forKey: key) + drawPoint(point, nil, terminal: terminal) + } + } else { + let content = (char: brushes[currentBrushIndex].char, color: brushes[currentBrushIndex].color) + canvas[key] = content + drawPoint(point, content, terminal: terminal) + } + } + } + } else { + if isInCanvas(position) { + let key = positionKey(position) + if currentBrushIndex == brushes.count - 1 { + if canvas[key] != nil { + canvas.removeValue(forKey: key) + drawPoint(position, nil, terminal: terminal) + } + } else { + let content = (char: brushes[currentBrushIndex].char, color: brushes[currentBrushIndex].color) + canvas[key] = content + drawPoint(position, content, terminal: terminal) + } + } + } + lastPosition = position + } + + private func drawScreen( + terminal: Terminal, + brushes: [(char: String, color: (String) -> String)], + currentBrushIndex: Int, + canvas: [String: (char: String, color: (String) -> String)] + ) { + var output = "\u{1B}[2J\u{1B}[H" // Clear screen and move to home + + let brush = brushes[currentBrushIndex] + output += Noora().format(""" + \(.accent("✨ Terminal Paint ✨")) + • \(.primary("left click")) to draw, \(.primary("right click")) to change brush + • \(.primary("'q'")) to quit, \(.primary("'c'")) to clear + Current brush: \(brush.color(brush.char)) + + """) + + let canvasTop = 5 + let terminalSize = terminal.size() + let canvasWidth = (terminalSize?.columns ?? 40) - 2 + let canvasHeight = (terminalSize?.rows ?? 120) - canvasTop + + // Top border + output += "\u{1B}[\(canvasTop);0H╭" + output += String(repeating: "─", count: canvasWidth) + output += "╮" + + // Side borders + for row in 1 ... canvasHeight { + output += "\u{1B}[\(canvasTop + row);0H│" + output += "\u{1B}[\(canvasTop + row);\(canvasWidth + 2)H│" + } + + // Bottom border + output += "\u{1B}[\(canvasTop + canvasHeight + 1);0H╰" + output += String(repeating: "─", count: canvasWidth) + output += "╯" + + print(output, terminator: "") + fflush(stdout) + + // Draw existing canvas points + for (key, content) in canvas { + let parts = key.split(separator: ";").compactMap { Int($0) } + if parts.count == 2 { + drawPoint(TerminalPosition(row: parts[0], column: parts[1]), content, terminal: terminal) + } + } + } +} diff --git a/Sources/examples-cli/ExamplesCLI.swift b/Sources/examples-cli/ExamplesCLI.swift index 3a9c652f..7706d6f9 100644 --- a/Sources/examples-cli/ExamplesCLI.swift +++ b/Sources/examples-cli/ExamplesCLI.swift @@ -14,6 +14,7 @@ struct ExamplesCLI: AsyncParsableCommand { ProgressStepCommand.self, CollapsibleStep.self, FormatCommand.self, + MouseTrackingCommand.self, ] ) } diff --git a/Tests/NooraTests/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index cb94cc2e..e416b374 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -27,6 +27,7 @@ struct SingleChoicePromptTests { terminal: terminal, collapseOnSelection: true, filterMode: .toggleable, + autoselectSingleChoice: false, renderer: renderer, standardPipelines: StandardPipelines(), keyStrokeListener: keyStrokeListener @@ -81,6 +82,7 @@ struct SingleChoicePromptTests { terminal: terminal, collapseOnSelection: true, filterMode: .toggleable, + autoselectSingleChoice: false, renderer: renderer, standardPipelines: StandardPipelines(), keyStrokeListener: keyStrokeListener @@ -131,6 +133,7 @@ struct SingleChoicePromptTests { terminal: terminal, collapseOnSelection: true, filterMode: .toggleable, + autoselectSingleChoice: false, renderer: renderer, standardPipelines: StandardPipelines(), keyStrokeListener: keyStrokeListener @@ -175,6 +178,7 @@ struct SingleChoicePromptTests { terminal: terminal, collapseOnSelection: true, filterMode: .toggleable, + autoselectSingleChoice: false, renderer: renderer, standardPipelines: StandardPipelines(), keyStrokeListener: keyStrokeListener @@ -244,4 +248,29 @@ struct SingleChoicePromptTests { ↑/↓/k/j up/down • / filter • enter confirm """) } + + @Test func auto_selects_single_item() throws { + // Given + let subject = SingleChoicePrompt( + title: nil, + question: "How would you like to integrate Tuist?", + description: nil, + theme: Theme.test(), + terminal: terminal, + collapseOnSelection: true, + filterMode: .toggleable, + autoselectSingleChoice: true, + renderer: renderer, + standardPipelines: StandardPipelines(), + keyStrokeListener: keyStrokeListener + ) + keyStrokeListener.keyPressStub = [] + + // When + let selectedItem = subject.run(options: ["single"]) + + // Then + #expect(selectedItem == "single") + #expect(renderer.renders == ["✔︎ How would you like to integrate Tuist?: single "]) + } } diff --git a/Tests/NooraTests/Mocks/MockTerminal.swift b/Tests/NooraTests/Mocks/MockTerminal.swift index ca6cd066..30666539 100644 --- a/Tests/NooraTests/Mocks/MockTerminal.swift +++ b/Tests/NooraTests/Mocks/MockTerminal.swift @@ -23,13 +23,28 @@ class MockTerminal: Terminaling { try body() } + func withMouseTracking( + trackMotion _: Bool, + _ body: () throws -> Void + ) rethrows { + try body() + } + var characters: [Character] = [] func readCharacter() -> Character? { - characters.removeFirst() + if !characters.isEmpty { + characters.removeFirst() + } else { + nil + } } func readCharacterNonBlocking() -> Character? { - nil + if !characters.isEmpty { + characters.removeFirst() + } else { + nil + } } func size() -> TerminalSize? { diff --git a/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift b/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift new file mode 100644 index 00000000..a632bb91 --- /dev/null +++ b/Tests/NooraTests/Utilities/KeyStrokeListenerTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import Noora + +struct KeyStrokeListenerTests { + let terminal = MockTerminal(size: .init(rows: 10, columns: 80)) + let keyStrokeListener = KeyStrokeListener() + + @Test func decodes_printable_characters() { + // When + let keystrokes = "Hello, World!" + terminal.characters = Array(keystrokes) + + // Then + var expectedKeyStrokes = keystrokes.map { KeyStroke.printable($0) } + var keystrokeCount = 0 + keyStrokeListener.listen(terminal: terminal) { keyStroke in + keystrokeCount += 1 + #expect(expectedKeyStrokes.removeFirst() == keyStroke) + return expectedKeyStrokes.isEmpty ? .abort : .continue + } + #expect(keystrokeCount == keystrokes.count) + } + + @Test func decodes_special_keys() { + // When + let keystrokes = [ + // Up arrow + "\u{1B}[A", + + // Down arrow + "\u{1B}[B", + + // Left arrow + "\u{1B}[D", + + // Right arrow + "\u{1B}[C", + + // Backspace + "\u{08}", + + // Delete + "\u{7F}", + + // Escape + "\u{1B}", + ] + terminal.characters = keystrokes.flatMap { $0 } + + // Then + var expectedKeyStrokes: [KeyStroke] = [ + .upArrowKey, + .downArrowKey, + .leftArrowKey, + .rightArrowKey, + .backspace, + .delete, + .escape, + ] + var keystrokeCount = 0 + keyStrokeListener.listen(terminal: terminal) { keyStroke in + keystrokeCount += 1 + #expect(expectedKeyStrokes.removeFirst() == keyStroke) + return expectedKeyStrokes.isEmpty ? .abort : .continue + } + #expect(keystrokeCount == keystrokes.count) + } + + @Test func decodes_mouse_click_and_motion() { + // When + let keystrokes = [ + // Mouse move (2,1) + "\u{1B}[<35;1;2m", + + // Left mouse down (2,1) + "\u{1B}[<0;1;2M", + + // Left mouse drag (3,1) -> (4,1) + "\u{1B}[<32;1;3M", + "\u{1B}[<32;1;4M", + + // Left mouse up (4,1) + "\u{1B}[<0;1;4m", + + // Right mouse down (2,1) + "\u{1B}[<2;1;2M", + + // Right mouse drag (3,1) -> (4,1) + "\u{1B}[<34;1;3M", + "\u{1B}[<34;1;4M", + + // Right mouse up (4,1) + "\u{1B}[<2;1;4m", + ] + terminal.characters = keystrokes.flatMap { $0 } + + // Then + var expectedKeyStrokes: [KeyStroke] = [ + .mouseMoved(position: TerminalPosition(row: 2, column: 1)), + .leftMouseDown(position: TerminalPosition(row: 2, column: 1)), + .leftMouseDrag(position: TerminalPosition(row: 3, column: 1)), + .leftMouseDrag(position: TerminalPosition(row: 4, column: 1)), + .leftMouseUp(position: TerminalPosition(row: 4, column: 1)), + .rightMouseDown(position: TerminalPosition(row: 2, column: 1)), + .rightMouseDrag(position: TerminalPosition(row: 3, column: 1)), + .rightMouseDrag(position: TerminalPosition(row: 4, column: 1)), + .rightMouseUp(position: TerminalPosition(row: 4, column: 1)), + ] + var keystrokeCount = 0 + keyStrokeListener.listen(terminal: terminal) { keyStroke in + keystrokeCount += 1 + #expect(expectedKeyStrokes.removeFirst() == keyStroke) + return expectedKeyStrokes.isEmpty ? .abort : .continue + } + #expect(keystrokeCount == keystrokes.count) + } +} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 86930559..6a39086d 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -45,10 +45,6 @@ export default defineConfig({ text: "Noora", link: "/", }, - { - text: "Text Styling", - link: "/text-styling", - }, { text: "Components", items: [ @@ -111,6 +107,10 @@ export default defineConfig({ text: "Keystroke listener", link: "/utilities/keystroke-listener", }, + { + text: "Text styling", + link: "/utilities/text-styling", + }, ], }, ], diff --git a/docs/content/components/prompts/single-choice.md b/docs/content/components/prompts/single-choice.md index 8585072c..6b20b725 100644 --- a/docs/content/components/prompts/single-choice.md +++ b/docs/content/components/prompts/single-choice.md @@ -74,3 +74,4 @@ let selectedOption = Noora().singleChoicePrompt( | `description` | A description that provides more context about the question. | No | | | `collapseOnSelection` | Whether the prompt should collapse after the user selects an option. | No | `true` | | `filterMode` | Whether the list of options should be filterable. | No | `disabled` | +| `autoselectSingleChoice` | Whether the prompt should automatically select the first item when options only contains one item. | No | `true` | diff --git a/docs/content/public/utilities/mouse-tracking.gif b/docs/content/public/utilities/mouse-tracking.gif new file mode 100644 index 00000000..41f50630 Binary files /dev/null and b/docs/content/public/utilities/mouse-tracking.gif differ diff --git a/docs/content/public/text-styling.png b/docs/content/public/utilities/text-styling.png similarity index 100% rename from docs/content/public/text-styling.png rename to docs/content/public/utilities/text-styling.png diff --git a/docs/content/utilities/keystroke-listener.md b/docs/content/utilities/keystroke-listener.md index 89456bda..d9764aff 100644 --- a/docs/content/utilities/keystroke-listener.md +++ b/docs/content/utilities/keystroke-listener.md @@ -9,13 +9,95 @@ description: A utility to listen for keystrokes. When building a CLI, you might need to observe keystrokes, for example to execute an action as a response to a key press (e.g. before taking the user to the browser for authentication). -Noora provides a utility, `KeyStrokeListener`, which you can use for that: +## Keyboard Events + +You can listen for both printable characters and special keys using the `KeyStrokeListener`. + +Raw mode must be enabled for the `KeyStrokeListener` to work properly. This ensures individual keystrokes and special keys are captured correctly. Use the terminal's `inRawMode` method to enable it: + +```swift +let terminal = Terminal() +let listener = KeyStrokeListener() + +terminal.inRawMode { + listener.listen(terminal: terminal) { keystroke in + // Handle keystrokes + } +} +``` + +### Example + +```swift +let terminal = Terminal() +let listener = KeyStrokeListener() + +terminal.inRawMode { + listener.listen(terminal: terminal) { keystroke in + switch keystroke { + case .printable(let char): + print("Received character: \(char)") + case .upArrowKey: + print("Up arrow pressed") + case .downArrowKey: + print("Down arrow pressed") + case .escape: + return .abort // Stop listening + default: + return .continue + } + return .continue + } +} +``` + +## Mouse Events + +To receive mouse events, you need to enable mouse tracking mode. You can do this using the terminal's `withMouseTracking` method. + +### Demo + +![A gif demonstrating a command line drawing program built using Noora's mouse tracking. The user clicks and drags to draw.](/utilities/mouse-tracking.gif) + +### Example ```swift -let keystrokeListener = KeyStrokeListener() -keystrokeListener.listen { key in - case key { - // Match the key you are interested in. - } +let terminal = Terminal() +let listener = KeyStrokeListener() + +terminal.inRawMode { + terminal.withMouseTracking(trackMotion: true) { + listener.listen(terminal: terminal) { keystroke in + switch keystroke { + case .leftMouseDown(let position): + print("Left click at row: \(position.row), column: \(position.column)") + case .mouseMoved(let position): + print("Mouse moved to row: \(position.row), column: \(position.column)") + case .escape: + return .abort + default: + return .continue + } + return .continue + } + } } ``` + +### Mouse Tracking Options + +When calling `withMouseTracking`, you can specify: + +- `trackMotion: false` (default) - Only receive click events +- `trackMotion: true` - Receive click, drag, and hover events + +## Return Value + +The keystroke handler must return an `OnKeyPressResult`: + +| Value | Description | +| --- | --- | +| `.continue` | Continue listening for more keystrokes | +| `.abort` | Stop listening and exit the loop | + +This allows you to control when to stop listening for events, such as when the user presses escape or when you've received all the input you need. diff --git a/docs/content/text-styling.md b/docs/content/utilities/text-styling.md similarity index 98% rename from docs/content/text-styling.md rename to docs/content/utilities/text-styling.md index 30f67a15..347fd5b0 100644 --- a/docs/content/text-styling.md +++ b/docs/content/utilities/text-styling.md @@ -46,7 +46,7 @@ let noora = Noora() let formattedText = noora.format(text) ``` -![A screenshot showing styled text in the terminal, with colors and formatting applied based on the TerminalText component used.](/text-styling.png) +![A screenshot showing styled text in the terminal, with colors and formatting applied based on the TerminalText component used.](/utilities/text-styling.png) ## Examples diff --git a/mise.toml b/mise.toml index 1f3f827e..34a3ae66 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,7 @@ "tuist" = "4.43.2" "swiftlint" = "0.57.0" "swiftformat" = "0.54.5" -"pnpm" = "10.5.0" +"pnpm" = "10.5.2" "node" = "22.14.0" "git-cliff" = "2.4.0" diff --git a/mise/tasks/test-linux b/mise/tasks/test-linux index 67c773b7..de8724e4 100755 --- a/mise/tasks/test-linux +++ b/mise/tasks/test-linux @@ -7,4 +7,4 @@ podman run --rm \ --workdir "/package" \ swift:6.0.0 \ /bin/bash -c \ - "swift test --configuration release --build-path ./.build/linux" + "swift test --build-path ./.build/linux"