diff --git a/Sources/Noora/Components/SingleChoicePrompt.swift b/Sources/Noora/Components/SingleChoicePrompt.swift index c530f23..7b413fe 100644 --- a/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/Sources/Noora/Components/SingleChoicePrompt.swift @@ -1,13 +1,12 @@ import Foundation import Rainbow -struct SingleChoicePrompt { +struct SingleChoicePrompt { // MARK: - Attributes let title: TerminalText? let question: TerminalText let description: TerminalText? - let options: T.Type let theme: Theme let terminal: Terminaling let collapseOnSelection: Bool @@ -15,29 +14,38 @@ struct SingleChoicePrompt let standardPipelines: StandardPipelines let keyStrokeListener: KeyStrokeListening - func run() -> T { + func run(options: [T]) -> T { + run(options: options.map { ($0, $0.description) }) + } + + func run() -> T { + run(options: Array(T.allCases).map { ($0, $0.description) }) + } + + // MARK: - Private + + private func run(options: [(T, String)]) -> T { if !terminal.isInteractive { fatalError("'\(question)' can't be prompted in a non-interactive session.") } - - let allOptions = Array(T.allCases) - var selectedOption: T! = allOptions.first + var options = options + var selectedOption: (T, String)! = options.first terminal.inRawMode { - renderOptions(selectedOption: selectedOption) + renderOptions(selectedOption: selectedOption, options: options) keyStrokeListener.listen(terminal: terminal) { keyStroke in switch keyStroke { case .returnKey: return .abort case .kKey, .upArrowKey: - let currentIndex = allOptions.firstIndex(where: { $0 == selectedOption })! - selectedOption = allOptions[(currentIndex - 1 + allOptions.count) % allOptions.count] - renderOptions(selectedOption: selectedOption) + let currentIndex = options.firstIndex(where: { $0 == selectedOption })! + selectedOption = options[(currentIndex - 1 + options.count) % options.count] + renderOptions(selectedOption: selectedOption, options: options) return .continue case .jKey, .downArrowKey: - let currentIndex = allOptions.firstIndex(where: { $0 == selectedOption })! - selectedOption = allOptions[(currentIndex + 1 + allOptions.count) % allOptions.count] - renderOptions(selectedOption: selectedOption) + let currentIndex = options.firstIndex(where: { $0 == selectedOption })! + selectedOption = options[(currentIndex + 1 + options.count) % options.count] + renderOptions(selectedOption: selectedOption, options: options) return .continue default: return .continue @@ -49,12 +57,10 @@ struct SingleChoicePrompt renderResult(selectedOption: selectedOption) } - return selectedOption + return selectedOption.0 } - // MARK: - Private - - private func renderResult(selectedOption: T) { + private func renderResult(selectedOption: (some Equatable, String)) { var content = if let title { "\(title.formatted(theme: theme, terminal: terminal)):".hexIfColoredTerminal(theme.primary, terminal) .boldIfColoredTerminal(terminal) @@ -62,21 +68,19 @@ struct SingleChoicePrompt "\(question.formatted(theme: theme, terminal: terminal)):".hexIfColoredTerminal(theme.primary, terminal) .boldIfColoredTerminal(terminal) } - content += " \(selectedOption.description)" + content += " \(selectedOption.1)" renderer.render( ProgressStep.completionMessage(content, theme: theme, terminal: terminal), standardPipeline: standardPipelines.output ) } - private func renderOptions(selectedOption: T) { - let options = Array(options.allCases) - + private func renderOptions(selectedOption: (T, String), options: [(T, String)]) { let questions = options.map { option in if option == selectedOption { - return " \("❯".hex(theme.primary)) \(option.description)" + return " \("❯".hex(theme.primary)) \(option.1)" } else { - return " \(option.description)" + return " \(option.1)" } }.joined(separator: "\n") var content = "" diff --git a/Sources/Noora/Noora.swift b/Sources/Noora/Noora.swift index 3611b1c..f57f093 100644 --- a/Sources/Noora/Noora.swift +++ b/Sources/Noora/Noora.swift @@ -58,8 +58,20 @@ public struct ErrorAlert: ExpressibleByStringLiteral { } public protocol Noorable { - func singleChoicePrompt( - question: TerminalText + /// It shows multiple options to the user to select one. + /// - Parameters: + /// - title: A title that captures what's being asked. + /// - question: The question to ask to the user. + /// - 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. + /// - Returns: The option selected by the user. + func singleChoicePrompt( + title: TerminalText?, + question: TerminalText, + options: [T], + description: TerminalText?, + collapseOnSelection: Bool ) -> T /// It shows multiple options to the user to select one. @@ -76,11 +88,6 @@ public protocol Noorable { collapseOnSelection: Bool ) -> T - func yesOrNoChoicePrompt( - title: TerminalText?, - question: TerminalText - ) -> Bool - /// It shows a component to answer yes or no to a question. /// - Parameters: /// - title: A title that captures what's being asked. @@ -112,16 +119,6 @@ public protocol Noorable { /// - alerts: The warning messages. func warning(_ alerts: WarningAlert...) - /// Shows a progress step. - /// - Parameters: - /// - message: The message that represents "what's being done" - /// - action: The asynchronous task to run. The caller can use the argument that the function takes to update the step - /// message. - func progressStep( - message: String, - action: @escaping ((String) -> Void) async throws -> Void - ) async throws - /// Shows a progress step. /// - Parameters: /// - message: The message that represents "what's being done" @@ -148,10 +145,25 @@ public struct Noora: Noorable { self.terminal = terminal } - public func singleChoicePrompt(question: TerminalText) -> T where T: CaseIterable, T: CustomStringConvertible, - T: Equatable - { - singleChoicePrompt(title: nil, question: question, description: nil, collapseOnSelection: true) + public func singleChoicePrompt( + title: TerminalText?, + question: TerminalText, + options: [T], + description: TerminalText?, + collapseOnSelection: Bool + ) -> T where T: CustomStringConvertible, T: Equatable { + let component = SingleChoicePrompt( + title: title, + question: question, + description: description, + theme: theme, + terminal: terminal, + collapseOnSelection: collapseOnSelection, + renderer: Renderer(), + standardPipelines: StandardPipelines(), + keyStrokeListener: KeyStrokeListener() + ) + return component.run(options: options) } public func singleChoicePrompt( @@ -160,11 +172,10 @@ public struct Noora: Noorable { description: TerminalText? = nil, collapseOnSelection: Bool = true ) -> T { - let component = SingleChoicePrompt( + let component = SingleChoicePrompt( title: title, question: question, description: description, - options: T.self, theme: theme, terminal: terminal, collapseOnSelection: collapseOnSelection, @@ -175,10 +186,6 @@ public struct Noora: Noorable { return component.run() } - public func yesOrNoChoicePrompt(title: TerminalText?, question: TerminalText) -> Bool { - yesOrNoChoicePrompt(title: title, question: question, defaultAnswer: true, description: nil, collapseOnSelection: true) - } - public func yesOrNoChoicePrompt( title: TerminalText? = nil, question: TerminalText, @@ -227,10 +234,6 @@ public struct Noora: Noorable { ).run() } - public func progressStep(message: String, action: @escaping ((String) -> Void) async throws -> Void) async throws { - try await progressStep(message: message, successMessage: nil, errorMessage: nil, showSpinner: true, action: action) - } - public func progressStep( message: String, successMessage: String? = nil, @@ -252,3 +255,67 @@ public struct Noora: Noorable { try await progressStep.run() } } + +extension Noorable { + public func singleChoicePrompt( + title: TerminalText? = nil, + question: TerminalText, + options: [T], + description: TerminalText? = nil, + collapseOnSelection: Bool = true + ) -> T { + singleChoicePrompt( + title: title, + question: question, + options: options, + description: description, + collapseOnSelection: collapseOnSelection + ) + } + + public func singleChoicePrompt( + title: TerminalText? = nil, + question: TerminalText, + description: TerminalText? = nil, + collapseOnSelection: Bool = true + ) -> T { + singleChoicePrompt( + title: title, + question: question, + description: description, + collapseOnSelection: collapseOnSelection + ) + } + + public func yesOrNoChoicePrompt( + title: TerminalText? = nil, + question: TerminalText, + defaultAnswer: Bool = true, + description: TerminalText? = nil, + collapseOnSelection: Bool = true + ) -> Bool { + yesOrNoChoicePrompt( + title: title, + question: question, + defaultAnswer: defaultAnswer, + description: description, + collapseOnSelection: collapseOnSelection + ) + } + + public func progressStep( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + showSpinner: Bool = true, + action: @escaping ((String) -> Void) async throws -> Void + ) async throws { + try await progressStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + showSpinner: showSpinner, + action: action + ) + } +} diff --git a/Tests/NooraTests/Components/SingleChoicePromptTests.swift b/Tests/NooraTests/Components/SingleChoicePromptTests.swift index 5ce5c49..c639666 100644 --- a/Tests/NooraTests/Components/SingleChoicePromptTests.swift +++ b/Tests/NooraTests/Components/SingleChoicePromptTests.swift @@ -23,7 +23,6 @@ struct SingleChoicePromptTests { title: "Integration", question: "How would you like to integrate Tuist?", description: "Decide how the integration should be with your project", - options: Option.self, theme: Theme.test(), terminal: terminal, collapseOnSelection: true, @@ -34,7 +33,7 @@ struct SingleChoicePromptTests { keyStrokeListener.keyPressStub = [.downArrowKey, .upArrowKey] // When - _ = subject.run() + let _: Option = subject.run() // Then var renders = Array(renderer.renders.reversed()) diff --git a/docs/content/components/prompts/single-choice.md b/docs/content/components/prompts/single-choice.md index 31dcd6c..8e38afa 100644 --- a/docs/content/components/prompts/single-choice.md +++ b/docs/content/components/prompts/single-choice.md @@ -18,7 +18,7 @@ This component is a simple component that prompts the user to select a single op ## API -### Example +### Example with a case iterable enum ```swift enum ProjectOption: String, CaseIterable, CustomStringConvertible { @@ -46,6 +46,21 @@ let selectedOption: ProjectOption = Noora().singleChoicePrompt( ) ``` +### Example with an `Equatable` and `CustomStringConvertible` type + +```swift +let selectedOption = Noora().singleChoicePrompt( + title: "Project", + question: "Would you like to create a new Tuist project or use an existing Xcode project?", + options: [ + "Create a new project", + "Use existing Xcode project" + ] + description: "Tuist extend the capabilities of your projects.", + theme: NooraTheme.tuist() +) +``` + ### Options | Attribute | Description | Required | Default value |