diff --git a/docs/input/assets/images/multiselection.gif b/docs/input/assets/images/multiselection.gif new file mode 100644 index 0000000..4889d16 Binary files /dev/null and b/docs/input/assets/images/multiselection.gif differ diff --git a/docs/input/assets/images/selection.gif b/docs/input/assets/images/selection.gif new file mode 100644 index 0000000..77befbd Binary files /dev/null and b/docs/input/assets/images/selection.gif differ diff --git a/docs/input/prompts/index.cshtml b/docs/input/prompts/index.cshtml new file mode 100644 index 0000000..cb7769c --- /dev/null +++ b/docs/input/prompts/index.cshtml @@ -0,0 +1,12 @@ +Title: Prompts +Order: 5 +--- + +

Sections

+ + \ No newline at end of file diff --git a/docs/input/prompts/multiselection.md b/docs/input/prompts/multiselection.md new file mode 100644 index 0000000..2e85133 --- /dev/null +++ b/docs/input/prompts/multiselection.md @@ -0,0 +1,31 @@ +Title: Multi Selection +Order: 3 +--- + +The `MultiSelectionPrompt` can be used when you want the user to select +one or many items from a provided list. + + + +# Usage + +```csharp +// Ask for the user's favorite fruits +var fruits = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title("What are your [green]favorite fruits[/]?") + .NotRequired() // Not required to have a favorite fruit + .PageSize(10) + .AddChoice("Apple") + .AddChoices(new[] { + "Apricot", "Avocado", + "Banana", "Blackcurrant", "Blueberry", + "Cherry", "Cloudberry", "Cocunut", + })); + +// Write the selected fruits to the terminal +foreach (string fruit in fruits) +{ + AnsiConsole.WriteLine(fruit); +} +``` \ No newline at end of file diff --git a/docs/input/prompts/selection.md b/docs/input/prompts/selection.md new file mode 100644 index 0000000..e3f846f --- /dev/null +++ b/docs/input/prompts/selection.md @@ -0,0 +1,27 @@ +Title: Selection +Order: 1 +--- + +The `SelectionPrompt` can be used when you want the user to select +a single item from a provided list. + + + +# Usage + +```csharp +// Ask for the user's favorite fruit +var fruit = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What's your [green]favorite fruit[/]?") + .PageSize(10) + .AddChoice("Apple") + .AddChoices(new[] { + "Apricot", "Avocado", + "Banana", "Blackcurrant", "Blueberry", + "Cherry", "Cloudberry", "Cocunut", + })); + +// Echo the fruit back to the terminal +AnsiConsole.WriteLine($"I agree. {fruit} is tasty!"); +``` \ No newline at end of file diff --git a/docs/input/prompt.md b/docs/input/prompts/text.md similarity index 97% rename from docs/input/prompt.md rename to docs/input/prompts/text.md index 8a68afa..9fb65e7 100644 --- a/docs/input/prompt.md +++ b/docs/input/prompts/text.md @@ -1,5 +1,6 @@ -Title: Prompt -Order: 4 +Title: Text +Order: 0 +RedirectFrom: prompt --- Sometimes you want to get some input from the user, and for this diff --git a/examples/Console/Prompt/Program.cs b/examples/Console/Prompt/Program.cs index 86728e5..edd83de 100644 --- a/examples/Console/Prompt/Program.cs +++ b/examples/Console/Prompt/Program.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Spectre.Console; namespace Cursor @@ -20,26 +21,87 @@ namespace Cursor return; } - // String + // Ask the user for some different things + var name = AskName(); + var fruit = AskFruit(); + var sport = AskSport(); + var age = AskAge(); + var password = AskPassword(); + var color = AskColor(); + + // 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]Favorite sport[/]", sport) + .AddRow("[grey]Age[/]", age.ToString()) + .AddRow("[grey]Password[/]", password) + .AddRow("[grey]Favorite color[/]", string.IsNullOrEmpty(color) ? "Unknown" : color)); + } + + private static string AskName() + { AnsiConsole.WriteLine(); AnsiConsole.Render(new Rule("[yellow]Strings[/]").RuleStyle("grey").LeftAligned()); var name = AnsiConsole.Ask("What's your [green]name[/]?"); + return name; + } - // String with choices + private static string AskFruit() + { + AnsiConsole.WriteLine(); + AnsiConsole.Render(new Rule("[yellow]Lists[/]").RuleStyle("grey").LeftAligned()); + + var favorites = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .PageSize(10) + .Title("What are your [green]favorite fruits[/]?") + .AddChoices(new[] + { + "Apple", "Apricot", "Avocado", "Banana", "Blackcurrant", "Blueberry", + "Cherry", "Cloudberry", "Cocunut", "Date", "Dragonfruit", "Durian", + "Egg plant", "Elderberry", "Fig", "Grape", "Guava", "Honeyberry", + "Jackfruit", "Jambul", "Kiwano", "Kiwifruit", "Lime", "Lylo", + "Lychee", "Melon", "Mulberry", "Nectarine", "Orange", "Olive" + })); + + var fruit = favorites.Count == 1 ? favorites[0] : null; + if (string.IsNullOrWhiteSpace(fruit)) + { + fruit = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Ok, but if you could only choose [green]one[/]?") + .AddChoices(favorites)); + } + + AnsiConsole.MarkupLine("Your selected: [yellow]{0}[/]", fruit); + return fruit; + } + + private static string AskSport() + { 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 + return AnsiConsole.Prompt( + new TextPrompt("What's your [green]favorite sport[/]?") + .InvalidChoiceMessage("[red]That's not a valid fruit[/]") + .DefaultValue("Lol") + .AddChoice("Soccer") + .AddChoice("Hockey") + .AddChoice("Basketball")); + } + + private static int AskAge() + { AnsiConsole.WriteLine(); AnsiConsole.Render(new Rule("[yellow]Integers[/]").RuleStyle("grey").LeftAligned()); - var age = AnsiConsole.Prompt( + + return AnsiConsole.Prompt( new TextPrompt("How [green]old[/] are you?") .PromptStyle("green") .ValidationErrorMessage("[red]That's not a valid age[/]") @@ -52,33 +114,27 @@ namespace Cursor _ => ValidationResult.Success(), }; })); + } - // Secret + private static string AskPassword() + { AnsiConsole.WriteLine(); AnsiConsole.Render(new Rule("[yellow]Secrets[/]").RuleStyle("grey").LeftAligned()); - var password = AnsiConsole.Prompt( + + return AnsiConsole.Prompt( new TextPrompt("Enter [green]password[/]?") .PromptStyle("red") .Secret()); + } - // Optional + private static string AskColor() + { AnsiConsole.WriteLine(); AnsiConsole.Render(new Rule("[yellow]Optional[/]").RuleStyle("grey").LeftAligned()); - var color = AnsiConsole.Prompt( + + return 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/src/Spectre.Console/Extensions/Int32Extensions.cs b/src/Spectre.Console/Extensions/Int32Extensions.cs new file mode 100644 index 0000000..a9f6c32 --- /dev/null +++ b/src/Spectre.Console/Extensions/Int32Extensions.cs @@ -0,0 +1,20 @@ +namespace Spectre.Console +{ + internal static class Int32Extensions + { + public static int Clamp(this int value, int min, int max) + { + if (value <= min) + { + return min; + } + + if (value >= max) + { + return max; + } + + return value; + } + } +} diff --git a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs index c41494a..e4c895a 100644 --- a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs @@ -26,10 +26,10 @@ namespace Spectre.Console.Internal { if (_out.IsStandardOut()) { - return ConsoleHelper.GetSafeBufferWidth(Constants.DefaultBufferWidth); + return ConsoleHelper.GetSafeWidth(Constants.DefaultTerminalWidth); } - return Constants.DefaultBufferWidth; + return Constants.DefaultTerminalWidth; } } @@ -39,10 +39,10 @@ namespace Spectre.Console.Internal { if (_out.IsStandardOut()) { - return ConsoleHelper.GetSafeBufferHeight(Constants.DefaultBufferHeight); + return ConsoleHelper.GetSafeHeight(Constants.DefaultTerminalHeight); } - return Constants.DefaultBufferHeight; + return Constants.DefaultTerminalHeight; } } diff --git a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs index 569db39..c6a9145 100644 --- a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs @@ -21,12 +21,12 @@ namespace Spectre.Console.Internal public int Width { - get { return ConsoleHelper.GetSafeBufferWidth(Constants.DefaultBufferWidth); } + get { return ConsoleHelper.GetSafeWidth(Constants.DefaultTerminalWidth); } } public int Height { - get { return ConsoleHelper.GetSafeBufferHeight(Constants.DefaultBufferHeight); } + get { return ConsoleHelper.GetSafeHeight(Constants.DefaultTerminalHeight); } } public FallbackBackend(TextWriter @out, Capabilities capabilities) diff --git a/src/Spectre.Console/Internal/ConsoleHelper.cs b/src/Spectre.Console/Internal/ConsoleHelper.cs index 0328c03..8ee8057 100644 --- a/src/Spectre.Console/Internal/ConsoleHelper.cs +++ b/src/Spectre.Console/Internal/ConsoleHelper.cs @@ -4,7 +4,7 @@ namespace Spectre.Console.Internal { internal static class ConsoleHelper { - public static int GetSafeBufferWidth(int defaultValue = Constants.DefaultBufferWidth) + public static int GetSafeWidth(int defaultValue = Constants.DefaultTerminalWidth) { try { @@ -22,11 +22,11 @@ namespace Spectre.Console.Internal } } - public static int GetSafeBufferHeight(int defaultValue = Constants.DefaultBufferWidth) + public static int GetSafeHeight(int defaultValue = Constants.DefaultTerminalHeight) { try { - var height = System.Console.BufferHeight; + var height = System.Console.WindowHeight; if (height == 0) { height = defaultValue; diff --git a/src/Spectre.Console/Internal/Constants.cs b/src/Spectre.Console/Internal/Constants.cs index 309a8ea..c7e40f6 100644 --- a/src/Spectre.Console/Internal/Constants.cs +++ b/src/Spectre.Console/Internal/Constants.cs @@ -2,8 +2,8 @@ namespace Spectre.Console.Internal { internal static class Constants { - public const int DefaultBufferWidth = 80; - public const int DefaultBufferHeight = 9001; + public const int DefaultTerminalWidth = 80; + public const int DefaultTerminalHeight = 24; public const string EmptyLink = "https://emptylink"; } diff --git a/src/Spectre.Console/Rendering/LiveRenderable.cs b/src/Spectre.Console/Rendering/LiveRenderable.cs index 1e4f621..7c3bf5d 100644 --- a/src/Spectre.Console/Rendering/LiveRenderable.cs +++ b/src/Spectre.Console/Rendering/LiveRenderable.cs @@ -9,6 +9,8 @@ namespace Spectre.Console.Rendering private IRenderable? _renderable; private SegmentShape? _shape; + public bool HasRenderable => _renderable != null; + public void SetRenderable(IRenderable renderable) { lock (_lock) diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs new file mode 100644 index 0000000..a7c960d --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a list prompt. + /// + /// The prompt result type. + public sealed class MultiSelectionPrompt : IPrompt> + { + /// + /// Gets or sets the title. + /// + public string? Title { get; set; } + + /// + /// Gets the choices. + /// + public List Choices { get; } + + /// + /// Gets or sets the converter to get the display string for a choice. By default + /// the corresponding is used. + /// + public Func? Converter { get; set; } = TypeConverterHelper.ConvertToString; + + /// + /// Gets or sets the page size. + /// Defaults to 10. + /// + public int PageSize { get; set; } = 10; + + /// + /// Gets or sets a value indicating whether or not + /// at least one selection is required. + /// + public bool Required { get; set; } = true; + + /// + /// Initializes a new instance of the class. + /// + public MultiSelectionPrompt() + { + Choices = new List(); + } + + /// + public List Show(IAnsiConsole console) + { + if (!console.Capabilities.SupportsInteraction) + { + throw new NotSupportedException( + "Cannot show multi selection prompt since the current " + + "terminal isn't interactive."); + } + + if (!console.Capabilities.SupportsAnsi) + { + throw new NotSupportedException( + "Cannot show multi selection prompt since the current " + + "terminal does not support ANSI escape sequences."); + } + + var converter = Converter ?? TypeConverterHelper.ConvertToString; + + var list = new RenderableMultiSelectionList(console, Title, PageSize, Choices, converter); + using (new RenderHookScope(console, list)) + { + console.Cursor.Hide(); + list.Redraw(); + + while (true) + { + var key = console.Input.ReadKey(true); + if (key.Key == ConsoleKey.Enter) + { + if (Required && list.Selections.Count == 0) + { + continue; + } + + break; + } + + if (key.Key == ConsoleKey.Spacebar) + { + list.Select(); + list.Redraw(); + continue; + } + + if (list.Update(key.Key)) + { + list.Redraw(); + } + } + } + + list.Clear(); + console.Cursor.Show(); + + return list.Selections + .Select(index => Choices[index]) + .ToList(); + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs new file mode 100644 index 0000000..410172d --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class MultiSelectionPromptExtensions + { + /// + /// Adds a choice. + /// + /// The prompt result type. + /// The prompt. + /// The choice to add. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt AddChoice(this MultiSelectionPrompt obj, T choice) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.Add(choice); + return obj; + } + + /// + /// Adds multiple choices. + /// + /// The prompt result type. + /// The prompt. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt AddChoices(this MultiSelectionPrompt obj, params T[] choices) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.AddRange(choices); + return obj; + } + + /// + /// Adds multiple choices. + /// + /// The prompt result type. + /// The prompt. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt AddChoices(this MultiSelectionPrompt obj, IEnumerable choices) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.AddRange(choices); + return obj; + } + + /// + /// Sets the title. + /// + /// The prompt result type. + /// The prompt. + /// The title markup text. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt Title(this MultiSelectionPrompt obj, string? title) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Title = title; + return obj; + } + + /// + /// Sets how many choices that are displayed to the user. + /// + /// The prompt result type. + /// The prompt. + /// The number of choices that are displayed to the user. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt PageSize(this MultiSelectionPrompt obj, int pageSize) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + if (pageSize <= 2) + { + throw new ArgumentException("Page size must be greater or equal to 3.", nameof(pageSize)); + } + + obj.PageSize = pageSize; + return obj; + } + + /// + /// Requires no choice to be selected. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt NotRequired(this MultiSelectionPrompt obj) + { + return Required(obj, false); + } + + /// + /// Requires a choice to be selected. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt Required(this MultiSelectionPrompt obj) + { + return Required(obj, true); + } + + /// + /// Sets a value indicating whether or not at least one choice must be selected. + /// + /// The prompt result type. + /// The prompt. + /// Whether or not at least one choice must be selected. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt Required(this MultiSelectionPrompt obj, bool required) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Required = required; + return obj; + } + + /// + /// Sets the function to create a display string for a given choice. + /// + /// The prompt type. + /// The prompt. + /// The function to get a display string for a given choice. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt UseConverter(this MultiSelectionPrompt obj, Func? displaySelector) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Converter = displaySelector; + return obj; + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs new file mode 100644 index 0000000..6931bbc --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal abstract class RenderableList : IRenderHook + { + private readonly LiveRenderable _live; + private readonly object _lock; + private readonly IAnsiConsole _console; + private readonly int _requestedPageSize; + private readonly List _choices; + private readonly Func _converter; + private int _index; + + public int Index => _index; + + public RenderableList(IAnsiConsole console, int requestedPageSize, List choices, Func? converter) + { + _console = console; + _requestedPageSize = requestedPageSize; + _choices = choices; + _converter = converter ?? throw new ArgumentNullException(nameof(converter)); + _live = new LiveRenderable(); + _lock = new object(); + _index = 0; + } + + protected abstract int CalculatePageSize(int requestedPageSize); + protected abstract IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices); + + public void Clear() + { + _console.Render(_live.RestoreCursor()); + } + + public void Redraw() + { + _console.Render(new ControlSequence(string.Empty)); + } + + public bool Update(ConsoleKey key) + { + var index = key switch + { + ConsoleKey.UpArrow => _index - 1, + ConsoleKey.DownArrow => _index + 1, + _ => _index, + }; + + index = index.Clamp(0, _choices.Count - 1); + if (index != _index) + { + _index = index; + Build(); + return true; + } + + return false; + } + + public IEnumerable Process(RenderContext context, IEnumerable renderables) + { + lock (_lock) + { + if (!_live.HasRenderable) + { + Build(); + } + + yield return _live.PositionCursor(); + + foreach (var renderable in renderables) + { + yield return renderable; + } + + yield return _live; + } + } + + protected void Build() + { + var pageSize = CalculatePageSize(_requestedPageSize); + var middleOfList = pageSize / 2; + + var skip = 0; + var take = _choices.Count; + var pointer = _index; + + var scrollable = _choices.Count > pageSize; + if (scrollable) + { + skip = Math.Max(0, _index - middleOfList); + take = Math.Min(pageSize, _choices.Count - skip); + + if (_choices.Count - _index < middleOfList) + { + // Pointer should be below the end of the list + var diff = middleOfList - (_choices.Count - _index); + skip -= diff; + take += diff; + pointer = middleOfList + diff; + } + else + { + // Take skip into account + pointer -= skip; + } + } + + // Build the list + _live.SetRenderable(Build( + pointer, + scrollable, + _choices.Skip(skip).Take(take) + .Enumerate() + .Select(x => (skip + x.Index, x.Index, _converter(x.Item))))); + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs new file mode 100644 index 0000000..1b1e9b3 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal sealed class RenderableMultiSelectionList : RenderableList + { + private const string Checkbox = "[[ ]]"; + private const string SelectedCheckbox = "[[X]]"; + + private readonly IAnsiConsole _console; + private readonly string? _title; + private readonly Style _highlightStyle; + + public HashSet Selections { get; set; } + + public RenderableMultiSelectionList( + IAnsiConsole console, string? title, int pageSize, + List choices, Func? converter) + : base(console, pageSize, choices, converter) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _title = title; + _highlightStyle = new Style(foreground: Color.Blue); + + Selections = new HashSet(); + } + + public void Select() + { + if (Selections.Contains(Index)) + { + Selections.Remove(Index); + } + else + { + Selections.Add(Index); + } + + Build(); + } + + protected override int CalculatePageSize(int requestedPageSize) + { + var pageSize = requestedPageSize; + if (pageSize > _console.Height - 5) + { + pageSize = _console.Height - 5; + } + + return pageSize; + } + + protected override IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices) + { + var list = new List(); + + if (_title != null) + { + list.Add(new Markup(_title)); + } + + var grid = new Grid(); + grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap()); + grid.AddColumn(new GridColumn().Padding(0, 0, 0, 0)); + + if (_title != null) + { + grid.AddEmptyRow(); + } + + foreach (var choice in choices) + { + var current = choice.Index == pointerIndex; + var selected = Selections.Contains(choice.Original); + + var prompt = choice.Index == pointerIndex ? "> " : " "; + var checkbox = selected ? SelectedCheckbox : Checkbox; + + var style = current ? _highlightStyle : Style.Plain; + + grid.AddRow( + new Markup($"{prompt}{checkbox}", style), + new Markup(choice.Item.EscapeMarkup(), style)); + } + + list.Add(grid); + list.Add(Text.Empty); + + if (scrollable) + { + list.Add(new Markup("[grey](Move up and down to reveal more choices)[/]")); + } + + list.Add(new Markup("[grey](Press to select)[/]")); + + return new Rows(list); + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs new file mode 100644 index 0000000..508ab9d --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal sealed class RenderableSelectionList : RenderableList + { + private const string Prompt = ">"; + + private readonly IAnsiConsole _console; + private readonly string? _title; + private readonly Style _highlightStyle; + + public RenderableSelectionList(IAnsiConsole console, string? title, int requestedPageSize, List choices, Func? converter) + : base(console, requestedPageSize, choices, converter) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _title = title; + _highlightStyle = new Style(foreground: Color.Blue); + } + + protected override int CalculatePageSize(int requestedPageSize) + { + var pageSize = requestedPageSize; + if (pageSize > _console.Height - 4) + { + pageSize = _console.Height - 4; + } + + return pageSize; + } + + protected override IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices) + { + var list = new List(); + + if (_title != null) + { + list.Add(new Markup(_title)); + } + + var grid = new Grid(); + grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap()); + grid.AddColumn(new GridColumn().Padding(0, 0, 0, 0)); + + if (_title != null) + { + grid.AddEmptyRow(); + } + + foreach (var choice in choices) + { + var current = choice.Index == pointerIndex; + + var prompt = choice.Index == pointerIndex ? Prompt : string.Empty; + var style = current ? _highlightStyle : Style.Plain; + + grid.AddRow( + new Markup(prompt, style), + new Markup(choice.Item.EscapeMarkup(), style)); + } + + list.Add(grid); + + if (scrollable) + { + list.Add(Text.Empty); + list.Add(new Markup("[grey](Move up and down to reveal more choices)[/]")); + } + + return new Rows(list); + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs new file mode 100644 index 0000000..1feaaee --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a list prompt. + /// + /// The prompt result type. + public sealed class SelectionPrompt : IPrompt + { + /// + /// Gets or sets the title. + /// + public string? Title { get; set; } + + /// + /// Gets the choices. + /// + public List Choices { get; } + + /// + /// Gets or sets the converter to get the display string for a choice. By default + /// the corresponding is used. + /// + public Func? Converter { get; set; } = TypeConverterHelper.ConvertToString; + + /// + /// Gets or sets the page size. + /// Defaults to 10. + /// + public int PageSize { get; set; } = 10; + + /// + /// Initializes a new instance of the class. + /// + public SelectionPrompt() + { + Choices = new List(); + } + + /// + T IPrompt.Show(IAnsiConsole console) + { + if (!console.Capabilities.SupportsInteraction) + { + throw new NotSupportedException( + "Cannot show selection prompt since the current " + + "terminal isn't interactive."); + } + + if (!console.Capabilities.SupportsAnsi) + { + throw new NotSupportedException( + "Cannot show selection prompt since the current " + + "terminal does not support ANSI escape sequences."); + } + + var converter = Converter ?? TypeConverterHelper.ConvertToString; + + var list = new RenderableSelectionList(console, Title, PageSize, Choices, converter); + using (new RenderHookScope(console, list)) + { + console.Cursor.Hide(); + list.Redraw(); + + while (true) + { + var key = console.Input.ReadKey(true); + if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar) + { + break; + } + + if (list.Update(key.Key)) + { + list.Redraw(); + } + } + } + + list.Clear(); + console.Cursor.Show(); + + return Choices[list.Index]; + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs new file mode 100644 index 0000000..3590226 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class SelectionPromptExtensions + { + /// + /// Adds a choice. + /// + /// The prompt result type. + /// The prompt. + /// The choice to add. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt AddChoice(this SelectionPrompt obj, T choice) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.Add(choice); + return obj; + } + + /// + /// Adds multiple choices. + /// + /// The prompt result type. + /// The prompt. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt AddChoices(this SelectionPrompt obj, params T[] choices) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.AddRange(choices); + return obj; + } + + /// + /// Adds multiple choices. + /// + /// The prompt result type. + /// The prompt. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt AddChoices(this SelectionPrompt obj, IEnumerable choices) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Choices.AddRange(choices); + return obj; + } + + /// + /// Sets the title. + /// + /// The prompt result type. + /// The prompt. + /// The title markup text. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt Title(this SelectionPrompt obj, string? title) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Title = title; + return obj; + } + + /// + /// Sets how many choices that are displayed to the user. + /// + /// The prompt result type. + /// The prompt. + /// The number of choices that are displayed to the user. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt PageSize(this SelectionPrompt obj, int pageSize) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + if (pageSize <= 2) + { + throw new ArgumentException("Page size must be greater or equal to 3.", nameof(pageSize)); + } + + obj.PageSize = pageSize; + return obj; + } + + /// + /// Sets the function to create a display string for a given choice. + /// + /// The prompt type. + /// The prompt. + /// The function to get a display string for a given choice. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt UseConverter(this SelectionPrompt obj, Func? displaySelector) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Converter = displaySelector; + return obj; + } + } +}