diff --git a/examples/Info/Program.cs b/examples/Info/Program.cs index 63c234f..4aa82d4 100644 --- a/examples/Info/Program.cs +++ b/examples/Info/Program.cs @@ -1,3 +1,4 @@ +using System; using Spectre.Console; namespace InfoExample @@ -12,6 +13,7 @@ namespace InfoExample .AddRow("[b]Color system[/]", $"{AnsiConsole.Capabilities.ColorSystem}") .AddRow("[b]Supports ansi?[/]", $"{YesNo(AnsiConsole.Capabilities.SupportsAnsi)}") .AddRow("[b]Legacy console?[/]", $"{YesNo(AnsiConsole.Capabilities.LegacyConsole)}") + .AddRow("[b]Interactive?[/]", $"{YesNo(Environment.UserInteractive)}") .AddRow("[b]Buffer width[/]", $"{AnsiConsole.Console.Width}") .AddRow("[b]Buffer height[/]", $"{AnsiConsole.Console.Height}"); diff --git a/examples/Prompt/Program.cs b/examples/Prompt/Program.cs new file mode 100644 index 0000000..4cb42d8 --- /dev/null +++ b/examples/Prompt/Program.cs @@ -0,0 +1,77 @@ +using Spectre.Console; + +namespace Cursor +{ + public static class Program + { + public static void Main(string[] args) + { + // Confirmation + if (!AnsiConsole.Confirm("Run prompt example?")) + { + AnsiConsole.MarkupLine("Ok... :("); + return; + } + + // String + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Strings[/]").RuleStyle("grey").LeftAligned()); + var name = AnsiConsole.Ask("What's your [green]name[/]?"); + + // String with choices + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Choices[/]").RuleStyle("grey").LeftAligned()); + var fruit = AnsiConsole.Prompt( + new TextPrompt("What's your [green]favorite fruit[/]?") + .InvalidChoiceMessage("[red]That's not a valid fruit[/]") + .DefaultValue("Orange") + .AddChoice("Apple") + .AddChoice("Banana") + .AddChoice("Orange")); + + // Integer + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Integers[/]").RuleStyle("grey").LeftAligned()); + var age = AnsiConsole.Prompt( + new TextPrompt("How [green]old[/] are you?") + .PromptStyle("green") + .ValidationErrorMessage("[red]That's not a valid age[/]") + .Validate(age => + { + return age switch + { + <= 0 => ValidationResult.Error("[red]You must at least be 1 years old[/]"), + >= 123 => ValidationResult.Error("[red]You must be younger than the oldest person alive[/]"), + _ => ValidationResult.Success(), + }; + })); + + // Secret + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Secrets[/]").RuleStyle("grey").LeftAligned()); + var password = AnsiConsole.Prompt( + new TextPrompt("Enter [green]password[/]?") + .PromptStyle("red") + .Secret()); + + // Optional + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Optional[/]").RuleStyle("grey").LeftAligned()); + var color = AnsiConsole.Prompt( + new TextPrompt("[grey][[Optional]][/] What is your [green]favorite color[/]?") + .AllowEmpty()); + + // Summary + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Results[/]").RuleStyle("grey").LeftAligned()); + AnsiConsole.Render(new Table().AddColumns("[grey]Question[/]", "[grey]Answer[/]") + .RoundedBorder() + .BorderColor(Color.Grey) + .AddRow("[grey]Name[/]", name) + .AddRow("[grey]Favorite fruit[/]", fruit) + .AddRow("[grey]Age[/]", age.ToString()) + .AddRow("[grey]Password[/]", password) + .AddRow("[grey]Favorite color[/]", string.IsNullOrEmpty(color) ? "Unknown" : color)); + } + } +} diff --git a/examples/Prompt/Prompt.csproj b/examples/Prompt/Prompt.csproj new file mode 100644 index 0000000..21559d0 --- /dev/null +++ b/examples/Prompt/Prompt.csproj @@ -0,0 +1,16 @@ + + + + Exe + netcoreapp3.1 + 9 + false + Prompt + Demonstrates how to get input from a user. + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c3e7972..a70b904 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ true - 8.0 + 9.0 true embedded true diff --git a/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs b/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs deleted file mode 100644 index d4ce6c3..0000000 --- a/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.IO; -using System.Text; -using Spectre.Console.Rendering; - -namespace Spectre.Console.Tests.Tools -{ - public sealed class MarkupConsoleFixture : IDisposable, IAnsiConsole - { - private readonly StringWriter _writer; - private readonly IAnsiConsole _console; - - public string Output => _writer.ToString().TrimEnd('\n'); - - public Capabilities Capabilities => _console.Capabilities; - public Encoding Encoding => _console.Encoding; - public IAnsiConsoleCursor Cursor => _console.Cursor; - public int Width { get; } - public int Height => _console.Height; - - public MarkupConsoleFixture(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80) - { - _writer = new StringWriter(); - _console = AnsiConsole.Create(new AnsiConsoleSettings - { - Ansi = ansi, - ColorSystem = (ColorSystemSupport)system, - Out = _writer, - LinkIdentityGenerator = new TestLinkIdentityGenerator(), - }); - - Width = width; - } - - public void Dispose() - { - _writer?.Dispose(); - } - - public void Clear(bool home) - { - _console.Clear(home); - } - - public void Write(Segment segment) - { - _console.Write(segment); - } - } -} diff --git a/src/Spectre.Console.Tests/Tools/PlainConsole.cs b/src/Spectre.Console.Tests/Tools/PlainConsole.cs index ca31b53..6e9defa 100644 --- a/src/Spectre.Console.Tests/Tools/PlainConsole.cs +++ b/src/Spectre.Console.Tests/Tools/PlainConsole.cs @@ -12,10 +12,13 @@ namespace Spectre.Console.Tests public Capabilities Capabilities { get; } public Encoding Encoding { get; } public IAnsiConsoleCursor Cursor => throw new NotSupportedException(); + public TestableConsoleInput Input { get; } public int Width { get; } public int Height { get; } + IAnsiConsoleInput IAnsiConsole.Input => Input; + public Decoration Decoration { get; set; } public Color Foreground { get; set; } public Color Background { get; set; } @@ -36,6 +39,7 @@ namespace Spectre.Console.Tests Width = width; Height = height; Writer = new StringWriter(); + Input = new TestableConsoleInput(); } public void Dispose() diff --git a/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs b/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs similarity index 90% rename from src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs rename to src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs index de71485..ba986e5 100644 --- a/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs +++ b/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs @@ -18,6 +18,9 @@ namespace Spectre.Console.Tests public int Width { get; } public int Height => _console.Height; public IAnsiConsoleCursor Cursor => _console.Cursor; + public TestableConsoleInput Input { get; } + + IAnsiConsoleInput IAnsiConsole.Input => Input; public TestableAnsiConsole(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80) { @@ -31,6 +34,7 @@ namespace Spectre.Console.Tests }); Width = width; + Input = new TestableConsoleInput(); } public void Dispose() diff --git a/src/Spectre.Console.Tests/Tools/TestableConsoleInput.cs b/src/Spectre.Console.Tests/Tools/TestableConsoleInput.cs new file mode 100644 index 0000000..cd16bdd --- /dev/null +++ b/src/Spectre.Console.Tests/Tools/TestableConsoleInput.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console.Tests +{ + public sealed class TestableConsoleInput : IAnsiConsoleInput + { + private readonly Queue _input; + + public TestableConsoleInput() + { + _input = new Queue(); + } + + public void PushText(string input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + foreach (var character in input) + { + PushCharacter(character); + } + + PushKey(ConsoleKey.Enter); + } + + public void PushCharacter(char character) + { + var control = char.IsUpper(character); + _input.Enqueue(new ConsoleKeyInfo(character, (ConsoleKey)character, false, false, control)); + } + + public void PushKey(ConsoleKey key) + { + _input.Enqueue(new ConsoleKeyInfo((char)key, key, false, false, false)); + } + + public ConsoleKeyInfo ReadKey(bool intercept) + { + if (_input.Count == 0) + { + throw new InvalidOperationException("No input available."); + } + + return _input.Dequeue(); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/PromptTests.cs b/src/Spectre.Console.Tests/Unit/PromptTests.cs new file mode 100644 index 0000000..4fe05f1 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/PromptTests.cs @@ -0,0 +1,126 @@ +using System; +using Shouldly; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class PromptTests + { + [Fact] + public void Should_Return_Validation_Error_If_Value_Cannot_Be_Converted() + { + // Given + var console = new PlainConsole(); + console.Input.PushText("ninety-nine"); + console.Input.PushText("99"); + + // When + console.Prompt(new TextPrompt("Age?")); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("Age? ninety-nine"); + console.Lines[1].ShouldBe("Invalid input"); + console.Lines[2].ShouldBe("Age? 99"); + } + + [Fact] + public void Should_Chose_Default_Value_If_Nothing_Is_Entered() + { + // Given + var console = new PlainConsole(); + console.Input.PushKey(ConsoleKey.Enter); + + // When + console.Prompt( + new TextPrompt("Favorite fruit?") + .AddChoice("Banana") + .AddChoice("Orange") + .DefaultValue("Banana")); + + // Then + console.Lines.Count.ShouldBe(1); + console.Lines[0].ShouldBe("Favorite fruit? [Banana/Orange] (Banana): Banana"); + } + + [Fact] + public void Should_Return_Error_If_An_Invalid_Choice_Is_Made() + { + // Given + var console = new PlainConsole(); + console.Input.PushText("Apple"); + console.Input.PushText("Banana"); + + // When + console.Prompt( + new TextPrompt("Favorite fruit?") + .AddChoice("Banana") + .AddChoice("Orange") + .DefaultValue("Banana")); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("Favorite fruit? [Banana/Orange] (Banana): Apple"); + console.Lines[1].ShouldBe("Please select one of the available options"); + console.Lines[2].ShouldBe("Favorite fruit? [Banana/Orange] (Banana): Banana"); + } + + [Fact] + public void Should_Accept_Choice_In_List() + { + // Given + var console = new PlainConsole(); + console.Input.PushText("Orange"); + + // When + console.Prompt( + new TextPrompt("Favorite fruit?") + .AddChoice("Banana") + .AddChoice("Orange") + .DefaultValue("Banana")); + + // Then + console.Lines.Count.ShouldBe(1); + console.Lines[0].ShouldBe("Favorite fruit? [Banana/Orange] (Banana): Orange"); + } + + [Fact] + public void Should_Return_Error_If_Custom_Validation_Fails() + { + // Given + var console = new PlainConsole(); + console.Input.PushText("22"); + console.Input.PushText("102"); + console.Input.PushText("ABC"); + console.Input.PushText("99"); + + // When + console.Prompt( + new TextPrompt("Guess number:") + .ValidationErrorMessage("Invalid input") + .Validate(age => + { + if (age < 99) + { + return ValidationResult.Error("Too low"); + } + else if (age > 99) + { + return ValidationResult.Error("Too high"); + } + + return ValidationResult.Success(); + })); + + // Then + console.Lines.Count.ShouldBe(7); + console.Lines[0].ShouldBe("Guess number: 22"); + console.Lines[1].ShouldBe("Too low"); + console.Lines[2].ShouldBe("Guess number: 102"); + console.Lines[3].ShouldBe("Too high"); + console.Lines[4].ShouldBe("Guess number: ABC"); + console.Lines[5].ShouldBe("Invalid input"); + console.Lines[6].ShouldBe("Guess number: 99"); + } + } +} diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index f40c75f..f24e9e8 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -50,6 +50,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rules", "..\examples\Rules\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cursor", "..\examples\Cursor\Cursor.csproj", "{75C608C3-ABB4-4168-A229-7F8250B946D1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Prompt", "..\examples\Prompt\Prompt.csproj", "{6351C70F-F368-46DB-BAED-9B87CCD69353}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -240,6 +242,18 @@ Global {75C608C3-ABB4-4168-A229-7F8250B946D1}.Release|x64.Build.0 = Release|Any CPU {75C608C3-ABB4-4168-A229-7F8250B946D1}.Release|x86.ActiveCfg = Release|Any CPU {75C608C3-ABB4-4168-A229-7F8250B946D1}.Release|x86.Build.0 = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|x64.ActiveCfg = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|x64.Build.0 = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|x86.ActiveCfg = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Debug|x86.Build.0 = Debug|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|Any CPU.Build.0 = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|x64.ActiveCfg = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|x64.Build.0 = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|x86.ActiveCfg = Release|Any CPU + {6351C70F-F368-46DB-BAED-9B87CCD69353}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -259,6 +273,7 @@ Global {57691C7D-683D-46E6-AA4F-57A8C5F65D25} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {8622A261-02C6-40CA-9797-E3F01ED87D6B} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {75C608C3-ABB4-4168-A229-7F8250B946D1} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {6351C70F-F368-46DB-BAED-9B87CCD69353} = {F0575243-121F-4DEE-9F6B-246E26DC0844} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/AnsiConsole.Prompt.cs b/src/Spectre.Console/AnsiConsole.Prompt.cs new file mode 100644 index 0000000..f1a162b --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Prompt.cs @@ -0,0 +1,47 @@ +using System; + +namespace Spectre.Console +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Displays a prompt to the user. + /// + /// The prompt result type. + /// The prompt to display. + /// The prompt input result. + public static T Prompt(IPrompt prompt) + { + if (prompt is null) + { + throw new ArgumentNullException(nameof(prompt)); + } + + return prompt.Show(Console); + } + + /// + /// Displays a prompt to the user. + /// + /// The prompt result type. + /// The prompt markup text. + /// The prompt input result. + public static T Ask(string prompt) + { + return new TextPrompt(prompt).Show(Console); + } + + /// + /// Displays a prompt with two choices, yes or no. + /// + /// The prompt markup text. + /// true if the user selected "yes", otherwise false. + public static bool Confirm(string prompt) + { + return new ConfirmationPrompt(prompt).Show(Console); + } + } +} diff --git a/src/Spectre.Console/ConfirmationPrompt.cs b/src/Spectre.Console/ConfirmationPrompt.cs new file mode 100644 index 0000000..5ee9983 --- /dev/null +++ b/src/Spectre.Console/ConfirmationPrompt.cs @@ -0,0 +1,62 @@ +namespace Spectre.Console +{ + /// + /// A prompt that is answered with a yes or no. + /// + public sealed class ConfirmationPrompt : IPrompt + { + private readonly string _prompt; + + /// + /// Gets or sets the character that represents "yes". + /// + public char Yes { get; set; } = 'y'; + + /// + /// Gets or sets the character that represents "no". + /// + public char No { get; set; } = 'n'; + + /// + /// Gets or sets the message for invalid choices. + /// + public string InvalidChoiceMessage { get; set; } = "[red]Please select one of the available options[/]"; + + /// + /// Gets or sets a value indicating whether or not + /// choices should be shown. + /// + public bool ShowChoices { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not + /// default values should be shown. + /// + public bool ShowDefaultValue { get; set; } = true; + + /// + /// Initializes a new instance of the class. + /// + /// The prompt markup text. + public ConfirmationPrompt(string prompt) + { + _prompt = prompt ?? throw new System.ArgumentNullException(nameof(prompt)); + } + + /// + public bool Show(IAnsiConsole console) + { + var prompt = new TextPrompt(_prompt) + .InvalidChoiceMessage(InvalidChoiceMessage) + .ValidationErrorMessage(InvalidChoiceMessage) + .ShowChoices(ShowChoices) + .ShowDefaultValue(ShowDefaultValue) + .DefaultValue(Yes) + .AddChoice(Yes) + .AddChoice(No); + + var result = prompt.Show(console); + return result == Yes; + } + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs new file mode 100644 index 0000000..d83e367 --- /dev/null +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs @@ -0,0 +1,49 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static partial class AnsiConsoleExtensions + { + internal static string ReadLine(this IAnsiConsole console, Style? style, bool secret) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + style ??= Style.Plain; + + var result = string.Empty; + while (true) + { + var key = console.Input.ReadKey(true); + + if (key.Key == ConsoleKey.Enter) + { + return result; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (result.Length > 0) + { + result = result.Substring(0, result.Length - 1); + console.Write("\b \b"); + } + + continue; + } + + result += key.KeyChar.ToString(); + + if (!char.IsControl(key.KeyChar)) + { + console.Write(secret ? "*" : key.KeyChar.ToString(), style); + } + } + } + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Prompt.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Prompt.cs new file mode 100644 index 0000000..8a94889 --- /dev/null +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Prompt.cs @@ -0,0 +1,50 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static partial class AnsiConsoleExtensions + { + /// + /// Displays a prompt to the user. + /// + /// The prompt result type. + /// The console. + /// The prompt to display. + /// The prompt input result. + public static T Prompt(this IAnsiConsole console, IPrompt prompt) + { + if (prompt is null) + { + throw new ArgumentNullException(nameof(prompt)); + } + + return prompt.Show(console); + } + + /// + /// Displays a prompt to the user. + /// + /// The prompt result type. + /// The console. + /// The prompt markup text. + /// The prompt input result. + public static T Ask(this IAnsiConsole console, string prompt) + { + return new TextPrompt(prompt).Show(console); + } + + /// + /// Displays a prompt with two choices, yes or no. + /// + /// The console. + /// The prompt markup text. + /// true if the user selected "yes", otherwise false. + public static bool Confirm(this IAnsiConsole console, string prompt) + { + return new ConfirmationPrompt(prompt).Show(console); + } + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs index 9a2abc4..aac10f7 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs @@ -18,6 +18,16 @@ namespace Spectre.Console return new Recorder(console); } + /// + /// Writes the specified string value to the console. + /// + /// The console to write to. + /// The text to write. + public static void Write(this IAnsiConsole console, string text) + { + Write(console, text, Style.Plain); + } + /// /// Writes the specified string value to the console. /// @@ -31,6 +41,11 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(console)); } + if (text is null) + { + throw new ArgumentNullException(nameof(text)); + } + console.Write(new Segment(text, style)); } @@ -48,6 +63,16 @@ namespace Spectre.Console console.Write(Environment.NewLine, Style.Plain); } + /// + /// Writes the specified string value, followed by the current line terminator, to the console. + /// + /// The console to write to. + /// The text to write. + public static void WriteLine(this IAnsiConsole console, string text) + { + WriteLine(console, text, Style.Plain); + } + /// /// Writes the specified string value, followed by the current line terminator, to the console. /// @@ -61,6 +86,11 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(console)); } + if (text is null) + { + throw new ArgumentNullException(nameof(text)); + } + console.Write(new Segment(text, style)); console.WriteLine(); } diff --git a/src/Spectre.Console/Extensions/ConfirmationPromptExtensions.cs b/src/Spectre.Console/Extensions/ConfirmationPromptExtensions.cs new file mode 100644 index 0000000..17c8add --- /dev/null +++ b/src/Spectre.Console/Extensions/ConfirmationPromptExtensions.cs @@ -0,0 +1,135 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class ConfirmationPromptExtensions + { + /// + /// Show or hide choices. + /// + /// The prompt. + /// Whether or not the choices should be visible. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt ShowChoices(this ConfirmationPrompt obj, bool show) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.ShowChoices = show; + return obj; + } + + /// + /// Shows choices. + /// + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt ShowChoices(this ConfirmationPrompt obj) + { + return ShowChoices(obj, true); + } + + /// + /// Hides choices. + /// + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt HideChoices(this ConfirmationPrompt obj) + { + return ShowChoices(obj, false); + } + + /// + /// Show or hide the default value. + /// + /// The prompt. + /// Whether or not the default value should be visible. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt ShowDefaultValue(this ConfirmationPrompt obj, bool show) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.ShowDefaultValue = show; + return obj; + } + + /// + /// Shows the default value. + /// + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt ShowDefaultValue(this ConfirmationPrompt obj) + { + return ShowDefaultValue(obj, true); + } + + /// + /// Hides the default value. + /// + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt HideDefaultValue(this ConfirmationPrompt obj) + { + return ShowDefaultValue(obj, false); + } + + /// + /// Sets the "invalid choice" message for the prompt. + /// + /// The prompt. + /// The "invalid choice" message. + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt InvalidChoiceMessage(this ConfirmationPrompt obj, string message) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.InvalidChoiceMessage = message; + return obj; + } + + /// + /// Sets the character to interpret as "yes". + /// + /// The confirmation prompt. + /// The character to interpret as "yes". + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt Yes(this ConfirmationPrompt obj, char character) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Yes = character; + return obj; + } + + /// + /// Sets the character to interpret as "no". + /// + /// The confirmation prompt. + /// The character to interpret as "no". + /// The same instance so that multiple calls can be chained. + public static ConfirmationPrompt No(this ConfirmationPrompt obj, char character) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.No = character; + return obj; + } + } +} diff --git a/src/Spectre.Console/Extensions/TextPromptExtensions.cs b/src/Spectre.Console/Extensions/TextPromptExtensions.cs new file mode 100644 index 0000000..1cfa1c0 --- /dev/null +++ b/src/Spectre.Console/Extensions/TextPromptExtensions.cs @@ -0,0 +1,266 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class TextPromptExtensions + { + /// + /// Allow empty input. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt AllowEmpty(this TextPrompt obj) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.AllowEmpty = true; + return obj; + } + + /// + /// Sets the prompt style. + /// + /// The prompt result type. + /// The prompt. + /// The prompt style. + /// The same instance so that multiple calls can be chained. + public static TextPrompt PromptStyle(this TextPrompt obj, Style style) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + obj.PromptStyle = style; + return obj; + } + + /// + /// Show or hide choices. + /// + /// The prompt result type. + /// The prompt. + /// Whether or not choices should be visible. + /// The same instance so that multiple calls can be chained. + public static TextPrompt ShowChoices(this TextPrompt obj, bool show) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.ShowChoices = show; + return obj; + } + + /// + /// Shows choices. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt ShowChoices(this TextPrompt obj) + { + return ShowChoices(obj, true); + } + + /// + /// Hides choices. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt HideChoices(this TextPrompt obj) + { + return ShowChoices(obj, false); + } + + /// + /// Show or hide the default value. + /// + /// The prompt result type. + /// The prompt. + /// Whether or not the default value should be visible. + /// The same instance so that multiple calls can be chained. + public static TextPrompt ShowDefaultValue(this TextPrompt obj, bool show) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.ShowDefaultValue = show; + return obj; + } + + /// + /// Shows the default value. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt ShowDefaultValue(this TextPrompt obj) + { + return ShowDefaultValue(obj, true); + } + + /// + /// Hides the default value. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt HideDefaultValue(this TextPrompt obj) + { + return ShowDefaultValue(obj, false); + } + + /// + /// Sets the validation error message for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The validation error message. + /// The same instance so that multiple calls can be chained. + public static TextPrompt ValidationErrorMessage(this TextPrompt obj, string message) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.ValidationErrorMessage = message; + return obj; + } + + /// + /// Sets the "invalid choice" message for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The "invalid choice" message. + /// The same instance so that multiple calls can be chained. + public static TextPrompt InvalidChoiceMessage(this TextPrompt obj, string message) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.InvalidChoiceMessage = message; + return obj; + } + + /// + /// Sets the default value of the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The default value. + /// The same instance so that multiple calls can be chained. + public static TextPrompt DefaultValue(this TextPrompt obj, T value) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.DefaultValue = new TextPrompt.DefaultValueContainer(value); + return obj; + } + + /// + /// Sets the validation criteria for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The validation criteria. + /// The validation error message. + /// The same instance so that multiple calls can be chained. + public static TextPrompt Validate(this TextPrompt obj, Func validator, string? message = null) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Validator = result => + { + if (validator(result)) + { + return ValidationResult.Success(); + } + + return ValidationResult.Error(message); + }; + + return obj; + } + + /// + /// Sets the validation criteria for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The validation criteria. + /// The same instance so that multiple calls can be chained. + public static TextPrompt Validate(this TextPrompt obj, Func validator) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Validator = validator; + + return obj; + } + + /// + /// Adds a choice to the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The choice to add. + /// The same instance so that multiple calls can be chained. + public static TextPrompt AddChoice(this TextPrompt obj, T choice) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.Add(choice); + return obj; + } + + /// + /// Replaces prompt user input with asterixes in the console. + /// + /// The prompt type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static TextPrompt Secret(this TextPrompt obj) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.IsSecret = true; + return obj; + } + } +} diff --git a/src/Spectre.Console/IAnsiConsole.cs b/src/Spectre.Console/IAnsiConsole.cs index 592dc4b..58c87e5 100644 --- a/src/Spectre.Console/IAnsiConsole.cs +++ b/src/Spectre.Console/IAnsiConsole.cs @@ -23,6 +23,11 @@ namespace Spectre.Console /// IAnsiConsoleCursor Cursor { get; } + /// + /// Gets the console input. + /// + IAnsiConsoleInput Input { get; } + /// /// Gets the buffer width of the console. /// diff --git a/src/Spectre.Console/IAnsiConsoleInput.cs b/src/Spectre.Console/IAnsiConsoleInput.cs new file mode 100644 index 0000000..f13bf93 --- /dev/null +++ b/src/Spectre.Console/IAnsiConsoleInput.cs @@ -0,0 +1,17 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Represents the console's input mechanism. + /// + public interface IAnsiConsoleInput + { + /// + /// Reads a key from the console. + /// + /// Whether or not to intercept the key. + /// The key that was read. + ConsoleKeyInfo ReadKey(bool intercept); + } +} diff --git a/src/Spectre.Console/IPrompt.cs b/src/Spectre.Console/IPrompt.cs new file mode 100644 index 0000000..4b56afc --- /dev/null +++ b/src/Spectre.Console/IPrompt.cs @@ -0,0 +1,16 @@ +namespace Spectre.Console +{ + /// + /// Represents a prompt. + /// + /// The prompt result type. + public interface IPrompt + { + /// + /// Shows the prompt. + /// + /// The console. + /// The prompt input result. + T Show(IAnsiConsole console); + } +} diff --git a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs index 082fac8..9a101d8 100644 --- a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs @@ -10,10 +10,12 @@ namespace Spectre.Console.Internal private readonly TextWriter _out; private readonly AnsiBuilder _ansiBuilder; private readonly AnsiCursor _cursor; + private readonly ConsoleInput _input; public Capabilities Capabilities { get; } public Encoding Encoding { get; } public IAnsiConsoleCursor Cursor => _cursor; + public IAnsiConsoleInput Input => _input; public int Width { @@ -50,6 +52,7 @@ namespace Spectre.Console.Internal _ansiBuilder = new AnsiBuilder(Capabilities, linkHasher); _cursor = new AnsiCursor(this); + _input = new ConsoleInput(); } public void Clear(bool home) diff --git a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs index 44c7c7b..81aff14 100644 --- a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs @@ -9,11 +9,13 @@ namespace Spectre.Console.Internal { private readonly ColorSystem _system; private readonly FallbackCursor _cursor; + private readonly ConsoleInput _input; private Style? _lastStyle; public Capabilities Capabilities { get; } public Encoding Encoding { get; } public IAnsiConsoleCursor Cursor => _cursor; + public IAnsiConsoleInput Input => _input; public int Width { @@ -34,6 +36,7 @@ namespace Spectre.Console.Internal _system = capabilities.ColorSystem; _cursor = new FallbackCursor(); + _input = new ConsoleInput(); if (@out != System.Console.Out) { diff --git a/src/Spectre.Console/Internal/ConsoleInput.cs b/src/Spectre.Console/Internal/ConsoleInput.cs new file mode 100644 index 0000000..19f4313 --- /dev/null +++ b/src/Spectre.Console/Internal/ConsoleInput.cs @@ -0,0 +1,17 @@ +using System; + +namespace Spectre.Console.Internal +{ + internal sealed class ConsoleInput : IAnsiConsoleInput + { + public ConsoleKeyInfo ReadKey(bool intercept) + { + if (!Environment.UserInteractive) + { + throw new InvalidOperationException("Failed to read input in non-interactive mode."); + } + + return System.Console.ReadKey(intercept); + } + } +} diff --git a/src/Spectre.Console/Recorder.cs b/src/Spectre.Console/Recorder.cs index 1ba1afb..11271a1 100644 --- a/src/Spectre.Console/Recorder.cs +++ b/src/Spectre.Console/Recorder.cs @@ -22,6 +22,9 @@ namespace Spectre.Console /// public IAnsiConsoleCursor Cursor => _console.Cursor; + /// + public IAnsiConsoleInput Input => _console.Input; + /// public int Width => _console.Width; diff --git a/src/Spectre.Console/Spectre.Console.csproj b/src/Spectre.Console/Spectre.Console.csproj index 4bc9466..e2aa4d9 100644 --- a/src/Spectre.Console/Spectre.Console.csproj +++ b/src/Spectre.Console/Spectre.Console.csproj @@ -10,27 +10,6 @@ - - - AnsiConsole.cs - - - Border.cs - - - BoxBorder.cs - - - Color.cs - - - Emoji.cs - - - Extensions/AnsiConsoleExtensions.cs - - - diff --git a/src/Spectre.Console/TextPrompt.cs b/src/Spectre.Console/TextPrompt.cs new file mode 100644 index 0000000..e50484c --- /dev/null +++ b/src/Spectre.Console/TextPrompt.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Spectre.Console +{ + /// + /// Represents a prompt. + /// + /// The prompt result type. + public sealed class TextPrompt : IPrompt + { + private readonly string _prompt; + + /// + /// Gets or sets the prompt style. + /// + public Style? PromptStyle { get; set; } + + /// + /// Gets the list of choices. + /// + public HashSet Choices { get; } + + /// + /// Gets or sets the message for invalid choices. + /// + public string InvalidChoiceMessage { get; set; } = "[red]Please select one of the available options[/]"; + + /// + /// Gets or sets a value indicating whether input should + /// be hidden in the console. + /// + public bool IsSecret { get; set; } + + /// + /// Gets or sets the validation error message. + /// + public string ValidationErrorMessage { get; set; } = "[red]Invalid input[/]"; + + /// + /// Gets or sets a value indicating whether or not + /// choices should be shown. + /// + public bool ShowChoices { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not + /// default values should be shown. + /// + public bool ShowDefaultValue { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not an empty result is valid. + /// + public bool AllowEmpty { get; set; } + + /// + /// Gets or sets the validator. + /// + public Func? Validator { get; set; } + + /// + /// Gets or sets the default value. + /// + internal DefaultValueContainer? DefaultValue { get; set; } + + /// + /// A nullable container for a default value. + /// + internal sealed class DefaultValueContainer + { + /// + /// Gets the default value. + /// + public T Value { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The default value. + public DefaultValueContainer(T value) + { + Value = value; + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The prompt markup text. + /// The comparer used for choices. + public TextPrompt(string prompt, IEqualityComparer? comparer = null) + { + _prompt = prompt; + + Choices = new HashSet(comparer ?? EqualityComparer.Default); + } + + /// + /// Shows the prompt and requests input from the user. + /// + /// The console to show the prompt in. + /// The user input converted to the expected type. + /// + public T Show(IAnsiConsole console) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + var promptStyle = PromptStyle ?? Style.Plain; + + WritePrompt(console); + + while (true) + { + var input = console.ReadLine(promptStyle, IsSecret); + + // Nothing entered? + if (string.IsNullOrWhiteSpace(input)) + { + if (DefaultValue != null) + { + console.Write(TextPrompt.GetTypeConverter().ConvertToInvariantString(DefaultValue.Value), promptStyle); + console.WriteLine(); + return DefaultValue.Value; + } + + if (!AllowEmpty) + { + continue; + } + } + + console.WriteLine(); + + // Try convert the value to the expected type. + if (!TextPrompt.TryConvert(input, out var result) || result == null) + { + console.MarkupLine(ValidationErrorMessage); + WritePrompt(console); + continue; + } + + if (Choices.Count > 0) + { + if (Choices.Contains(result)) + { + return result; + } + else + { + console.MarkupLine(InvalidChoiceMessage); + WritePrompt(console); + continue; + } + } + + // Run all validators + if (!ValidateResult(result, out var validationMessage)) + { + console.MarkupLine(validationMessage); + WritePrompt(console); + continue; + } + + return result; + } + } + + /// + /// Writes the prompt to the console. + /// + /// The console to write the prompt to. + private void WritePrompt(IAnsiConsole console) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + var builder = new StringBuilder(); + builder.Append(_prompt.TrimEnd()); + + if (ShowChoices && Choices.Count > 0) + { + var choices = string.Join("/", Choices.Select(choice => TextPrompt.GetTypeConverter().ConvertToInvariantString(choice))); + builder.AppendFormat(CultureInfo.InvariantCulture, " [blue][[{0}]][/]", choices); + } + + if (ShowDefaultValue && DefaultValue != null) + { + builder.AppendFormat( + CultureInfo.InvariantCulture, + " [green]({0})[/]", + TextPrompt.GetTypeConverter().ConvertToInvariantString(DefaultValue.Value)); + } + + var markup = builder.ToString().Trim(); + if (!markup.EndsWith("?", StringComparison.OrdinalIgnoreCase) && + !markup.EndsWith(":", StringComparison.OrdinalIgnoreCase)) + { + markup += ":"; + } + + console.Markup(markup + " "); + } + + /// + /// Tries to convert the input string to . + /// + /// The input to convert. + /// The result. + /// true if the conversion succeeded, otherwise false. + [SuppressMessage("Design", "CA1031:Do not catch general exception types")] + private static bool TryConvert(string input, [MaybeNull] out T result) + { + try + { + result = (T)TextPrompt.GetTypeConverter().ConvertFromInvariantString(input); + return true; + } + catch + { +#pragma warning disable CS8601 // Possible null reference assignment. + result = default; +#pragma warning restore CS8601 // Possible null reference assignment. + return false; + } + } + + /// + /// Gets the type converter that's used to convert values. + /// + /// The type converter that's used to convert values. + private static TypeConverter GetTypeConverter() + { + var converter = TypeDescriptor.GetConverter(typeof(T)); + if (converter != null) + { + return converter; + } + + var attribute = typeof(T).GetCustomAttribute(); + if (attribute != null) + { + var type = Type.GetType(attribute.ConverterTypeName, false, false); + if (type != null) + { + converter = Activator.CreateInstance(type) as TypeConverter; + if (converter != null) + { + return converter; + } + } + } + + throw new InvalidOperationException("Could not find type converter"); + } + + private bool ValidateResult(T value, [NotNullWhen(false)] out string? message) + { + if (Validator != null) + { + var result = Validator(value); + if (!result.Successful) + { + message = result.Message ?? ValidationErrorMessage; + return false; + } + } + + message = null; + return true; + } + } +} diff --git a/src/Spectre.Console/ValidationResult.cs b/src/Spectre.Console/ValidationResult.cs new file mode 100644 index 0000000..d96f413 --- /dev/null +++ b/src/Spectre.Console/ValidationResult.cs @@ -0,0 +1,43 @@ +namespace Spectre.Console +{ + /// + /// Represents a validation result. + /// + public sealed class ValidationResult + { + /// + /// Gets a value indicating whether or not validation was successful. + /// + public bool Successful { get; } + + /// + /// Gets the validation error message. + /// + public string? Message { get; } + + private ValidationResult(bool successful, string? message) + { + Successful = successful; + Message = message; + } + + /// + /// Returns a representing successful validation. + /// + /// The validation result. + public static ValidationResult Success() + { + return new ValidationResult(true, null); + } + + /// + /// Returns a representing a validation error. + /// + /// The validation error message, or null to show the default validation error message. + /// The validation result. + public static ValidationResult Error(string? message = null) + { + return new ValidationResult(false, message); + } + } +}