Skip to content

Commit

Permalink
feat: Support runtime options in the SingleChoicePrompt (#175)
Browse files Browse the repository at this point in the history
Co-authored-by: Christoph Schmatzler <[email protected]>
Co-authored-by: Marek Fořt <[email protected]>
  • Loading branch information
3 people authored Feb 12, 2025
1 parent d302171 commit e87d523
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 57 deletions.
50 changes: 27 additions & 23 deletions Sources/Noora/Components/SingleChoicePrompt.swift
Original file line number Diff line number Diff line change
@@ -1,43 +1,51 @@
import Foundation
import Rainbow

struct SingleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable> {
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
let renderer: Rendering
let standardPipelines: StandardPipelines
let keyStrokeListener: KeyStrokeListening

func run() -> T {
func run<T: CustomStringConvertible & Equatable>(options: [T]) -> T {
run(options: options.map { ($0, $0.description) })
}

func run<T: CaseIterable & CustomStringConvertible & Equatable>() -> T {
run(options: Array(T.allCases).map { ($0, $0.description) })
}

// MARK: - Private

private func run<T: Equatable>(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
Expand All @@ -49,34 +57,30 @@ struct SingleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>
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)
} else {
"\(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<T: Equatable>(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 = ""
Expand Down
129 changes: 98 additions & 31 deletions Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,20 @@ public struct ErrorAlert: ExpressibleByStringLiteral {
}

public protocol Noorable {
func singleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>(
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<T: Equatable & CustomStringConvertible>(
title: TerminalText?,
question: TerminalText,
options: [T],
description: TerminalText?,
collapseOnSelection: Bool
) -> T

/// It shows multiple options to the user to select one.
Expand All @@ -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.
Expand Down Expand Up @@ -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"
Expand All @@ -148,10 +145,25 @@ public struct Noora: Noorable {
self.terminal = terminal
}

public func singleChoicePrompt<T>(question: TerminalText) -> T where T: CaseIterable, T: CustomStringConvertible,
T: Equatable
{
singleChoicePrompt(title: nil, question: question, description: nil, collapseOnSelection: true)
public func singleChoicePrompt<T>(
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<T: CaseIterable & CustomStringConvertible & Equatable>(
Expand All @@ -160,11 +172,10 @@ public struct Noora: Noorable {
description: TerminalText? = nil,
collapseOnSelection: Bool = true
) -> T {
let component = SingleChoicePrompt<T>(
let component = SingleChoicePrompt(
title: title,
question: question,
description: description,
options: T.self,
theme: theme,
terminal: terminal,
collapseOnSelection: collapseOnSelection,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -252,3 +255,67 @@ public struct Noora: Noorable {
try await progressStep.run()
}
}

extension Noorable {
public func singleChoicePrompt<T: Equatable & CustomStringConvertible>(
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<T: CaseIterable & CustomStringConvertible & Equatable>(
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
)
}
}
3 changes: 1 addition & 2 deletions Tests/NooraTests/Components/SingleChoicePromptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +33,7 @@ struct SingleChoicePromptTests {
keyStrokeListener.keyPressStub = [.downArrowKey, .upArrowKey]

// When
_ = subject.run()
let _: Option = subject.run()

// Then
var renders = Array(renderer.renders.reversed())
Expand Down
17 changes: 16 additions & 1 deletion docs/content/components/prompts/single-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 |
Expand Down

0 comments on commit e87d523

Please sign in to comment.