diff --git a/cli/Sources/Noora/Components/TextPrompt.swift b/cli/Sources/Noora/Components/TextPrompt.swift index 2a93bd21..443ec4d9 100644 --- a/cli/Sources/Noora/Components/TextPrompt.swift +++ b/cli/Sources/Noora/Components/TextPrompt.swift @@ -6,6 +6,7 @@ struct TextPrompt { let title: TerminalText? let prompt: TerminalText let description: TerminalText? + let defaultValue: String? let theme: Theme let content: Content let terminal: Terminaling @@ -63,7 +64,14 @@ struct TextPrompt { logger?.debug("Prompted '\(prompt.plain())'") - let validationResult = validator.validate(input: input, rules: validationRules) + let resolvedInput: String + if input.isEmpty, let defaultValue { + resolvedInput = defaultValue + } else { + resolvedInput = input + } + + let validationResult = validator.validate(input: resolvedInput, rules: validationRules) switch validationResult { case .success: @@ -72,11 +80,11 @@ struct TextPrompt { return run(errors: error.errors) } - renderResult(input: input) + renderResult(input: resolvedInput) - logger?.debug("Responded \(input) to prompt '\(prompt.plain())'") + logger?.debug("Responded \(resolvedInput) to prompt '\(prompt.plain())'") - return input + return resolvedInput } private func render(input: String, withCursor: Bool = true, errors: [ValidatableError] = []) { @@ -88,9 +96,14 @@ struct TextPrompt { .boldIfColoredTerminal(terminal) } - let input = "\(input)\(withCursor ? "█" : "")".hexIfColoredTerminal(theme.secondary, terminal) + let inputDisplay = "\(input)\(withCursor ? "█" : "")".hexIfColoredTerminal(theme.secondary, terminal) - message += "\(title != nil ? "\n" : "")\(titleOffset)\(prompt.formatted(theme: theme, terminal: terminal)) \(input)" + message += "\(title != nil ? "\n" : "")\(titleOffset)\(prompt.formatted(theme: theme, terminal: terminal)) \(inputDisplay)" + + if input.isEmpty, let defaultValue, withCursor { + let defaultHint = "Press Enter to use \(defaultValue)".hexIfColoredTerminal(theme.muted, terminal) + message += "\n\(titleOffset)\(defaultHint)" + } if !errors.isEmpty { var errorMessage = "\(content.textPromptValidationErrorsTitle):\n\(titleOffset)" diff --git a/cli/Sources/Noora/Noora.swift b/cli/Sources/Noora/Noora.swift index 210ae560..93beac88 100644 --- a/cli/Sources/Noora/Noora.swift +++ b/cli/Sources/Noora/Noora.swift @@ -204,14 +204,17 @@ public protocol Noorable: Sendable { /// - title: The thing the user is being prompted for. /// - prompt: The prompt message. /// - description: An optional description to clarify what the prompt is for. - /// - collapseOnSelection: Whether the prompt should be collapsed on answered. + /// - defaultValue: An optional default value. When provided and the user submits empty input, + /// this value is returned. A hint is shown below the prompt while input is empty. + /// - collapseOnAnswer: Whether the prompt should be collapsed on answered. /// - renderer: A rendering interface that holds the UI state. /// - validationRules: An array of rules used for input validation. - /// - Returns: The user's response. + /// - Returns: The user's response, or the default value if the input was empty. func textPrompt( title: TerminalText?, prompt: TerminalText, description: TerminalText?, + defaultValue: String?, collapseOnAnswer: Bool, renderer: Rendering, validationRules: [ValidatableRule] @@ -618,6 +621,7 @@ public final class Noora: Noorable { title: TerminalText?, prompt: TerminalText, description: TerminalText?, + defaultValue: String?, collapseOnAnswer: Bool, renderer: Rendering, validationRules: [ValidatableRule] @@ -626,6 +630,7 @@ public final class Noora: Noorable { title: title, prompt: prompt, description: description, + defaultValue: defaultValue, theme: theme, content: content, terminal: terminal, @@ -1219,6 +1224,7 @@ extension Noorable { title: TerminalText? = nil, prompt: TerminalText, description: TerminalText? = nil, + defaultValue: String? = nil, collapseOnAnswer: Bool = true, renderer: Rendering = Renderer(), validationRules: [ValidatableRule] = [] @@ -1227,6 +1233,7 @@ extension Noorable { title: title, prompt: prompt, description: description, + defaultValue: defaultValue, collapseOnAnswer: collapseOnAnswer, renderer: renderer, validationRules: validationRules diff --git a/cli/Sources/Noora/NooraMock.swift b/cli/Sources/Noora/NooraMock.swift index 5b51b944..d2a30269 100644 --- a/cli/Sources/Noora/NooraMock.swift +++ b/cli/Sources/Noora/NooraMock.swift @@ -267,6 +267,7 @@ title: TerminalText?, prompt: TerminalText, description: TerminalText?, + defaultValue: String?, collapseOnAnswer: Bool, renderer: Rendering, validationRules: [ValidatableRule] @@ -275,6 +276,7 @@ title: title, prompt: prompt, description: description, + defaultValue: defaultValue, collapseOnAnswer: collapseOnAnswer, renderer: renderer, validationRules: validationRules diff --git a/cli/Tests/NooraTests/Components/TableTests.swift b/cli/Tests/NooraTests/Components/TableTests.swift index 3fa2da6d..621b8f83 100644 --- a/cli/Tests/NooraTests/Components/TableTests.swift +++ b/cli/Tests/NooraTests/Components/TableTests.swift @@ -128,11 +128,8 @@ struct TableTests { ]) let updates = AsyncStream { continuation in - Task { - try await Task.sleep(for: .milliseconds(20)) - continuation.yield(updatedData) - continuation.finish() - } + continuation.yield(updatedData) + continuation.finish() } let standardOutput = MockStandardPipeline() diff --git a/cli/Tests/NooraTests/Components/TextPromptTests.swift b/cli/Tests/NooraTests/Components/TextPromptTests.swift index 5593290a..807a4964 100644 --- a/cli/Tests/NooraTests/Components/TextPromptTests.swift +++ b/cli/Tests/NooraTests/Components/TextPromptTests.swift @@ -12,6 +12,7 @@ struct TextPromptTests { title: "Project", prompt: "How would you like to name your project?", description: "The generated project will take this name", + defaultValue: nil, theme: .test(), content: .default, terminal: terminal, @@ -87,6 +88,7 @@ struct TextPromptTests { title: nil, prompt: "How would you like to name your project?", description: "The generated project will take this name", + defaultValue: nil, theme: .test(), content: .default, terminal: terminal, @@ -146,4 +148,69 @@ struct TextPromptTests { """) #expect(validator.invokedValidateInputRulesCount == 1) } + + @Test func returns_default_value_when_input_is_empty() { + // Given + let subject = TextPrompt( + title: "Name", + prompt: "How would you like to name the project?", + description: nil, + defaultValue: "my-project", + theme: .test(), + content: .default, + terminal: terminal, + collapseOnAnswer: true, + renderer: renderer, + standardPipelines: StandardPipelines(), + logger: nil, + validationRules: [], + validator: validator + ) + terminal.characters = ["\n"] + + // When + let result = subject.run() + + // Then + #expect(result == "my-project") + var renders = Array(renderer.renders.reversed()) + #expect(renders.popLast() == """ + Name + How would you like to name the project? █ + Press Enter to use my-project + """) + #expect(renders.popLast() == """ + Name + How would you like to name the project? \("") + """) + #expect(renders.popLast() == """ + ✔︎ Name: my-project\(" ") + """) + } + + @Test func uses_typed_input_over_default_value() { + // Given + let subject = TextPrompt( + title: "Name", + prompt: "How would you like to name the project?", + description: nil, + defaultValue: "my-project", + theme: .test(), + content: .default, + terminal: terminal, + collapseOnAnswer: true, + renderer: renderer, + standardPipelines: StandardPipelines(), + logger: nil, + validationRules: [], + validator: validator + ) + terminal.characters = ["F", "o", "o", "\n"] + + // When + let result = subject.run() + + // Then + #expect(result == "Foo") + } }