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
+
+
+@foreach (IDocument child in OutputPages.GetChildrenOf(Document))
+{
+ - @Html.DocumentLink(child)
+}
+
\ 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;
+ }
+ }
+}