Add interactive prompts for selecting values

* Adds SelectionPrompt
* Adds MultiSelectionPrompt

Closes #210
This commit is contained in:
Patrik Svensson
2021-01-08 06:38:07 +01:00
committed by Patrik Svensson
parent 3a593857c8
commit 0e0f4b4220
20 changed files with 980 additions and 40 deletions

View File

@ -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
{
/// <summary>
/// Represents a list prompt.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>
{
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets the choices.
/// </summary>
public List<T> Choices { get; }
/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
public Func<T, string>? Converter { get; set; } = TypeConverterHelper.ConvertToString;
/// <summary>
/// Gets or sets the page size.
/// Defaults to <c>10</c>.
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary>
/// Gets or sets a value indicating whether or not
/// at least one selection is required.
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="MultiSelectionPrompt{T}"/> class.
/// </summary>
public MultiSelectionPrompt()
{
Choices = new List<T>();
}
/// <inheritdoc/>
public List<T> 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<T>(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();
}
}
}

View File

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="MultiSelectionPrompt{T}"/>.
/// </summary>
public static class MultiSelectionPromptExtensions
{
/// <summary>
/// Adds a choice.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choice">The choice to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> AddChoice<T>(this MultiSelectionPrompt<T> obj, T choice)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.Add(choice);
return obj;
}
/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, params T[] choices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.AddRange(choices);
return obj;
}
/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, IEnumerable<T> choices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.AddRange(choices);
return obj;
}
/// <summary>
/// Sets the title.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="title">The title markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Title<T>(this MultiSelectionPrompt<T> obj, string? title)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Title = title;
return obj;
}
/// <summary>
/// Sets how many choices that are displayed to the user.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="pageSize">The number of choices that are displayed to the user.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> PageSize<T>(this MultiSelectionPrompt<T> 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;
}
/// <summary>
/// Requires no choice to be selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> NotRequired<T>(this MultiSelectionPrompt<T> obj)
{
return Required(obj, false);
}
/// <summary>
/// Requires a choice to be selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj)
{
return Required(obj, true);
}
/// <summary>
/// Sets a value indicating whether or not at least one choice must be selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="required">Whether or not at least one choice must be selected.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj, bool required)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Required = required;
return obj;
}
/// <summary>
/// Sets the function to create a display string for a given choice.
/// </summary>
/// <typeparam name="T">The prompt type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="displaySelector">The function to get a display string for a given choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> UseConverter<T>(this MultiSelectionPrompt<T> obj, Func<T, string>? displaySelector)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Converter = displaySelector;
return obj;
}
}
}

View File

@ -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<T> : IRenderHook
{
private readonly LiveRenderable _live;
private readonly object _lock;
private readonly IAnsiConsole _console;
private readonly int _requestedPageSize;
private readonly List<T> _choices;
private readonly Func<T, string> _converter;
private int _index;
public int Index => _index;
public RenderableList(IAnsiConsole console, int requestedPageSize, List<T> choices, Func<T, string>? 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<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> 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)))));
}
}
}

View File

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class RenderableMultiSelectionList<T> : RenderableList<T>
{
private const string Checkbox = "[[ ]]";
private const string SelectedCheckbox = "[[X]]";
private readonly IAnsiConsole _console;
private readonly string? _title;
private readonly Style _highlightStyle;
public HashSet<int> Selections { get; set; }
public RenderableMultiSelectionList(
IAnsiConsole console, string? title, int pageSize,
List<T> choices, Func<T, string>? converter)
: base(console, pageSize, choices, converter)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_title = title;
_highlightStyle = new Style(foreground: Color.Blue);
Selections = new HashSet<int>();
}
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<IRenderable>();
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 <space> to select)[/]"));
return new Rows(list);
}
}
}

View File

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class RenderableSelectionList<T> : RenderableList<T>
{
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<T> choices, Func<T, string>? 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<IRenderable>();
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);
}
}
}

View File

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a list prompt.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
public sealed class SelectionPrompt<T> : IPrompt<T>
{
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets the choices.
/// </summary>
public List<T> Choices { get; }
/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
public Func<T, string>? Converter { get; set; } = TypeConverterHelper.ConvertToString;
/// <summary>
/// Gets or sets the page size.
/// Defaults to <c>10</c>.
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary>
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
/// </summary>
public SelectionPrompt()
{
Choices = new List<T>();
}
/// <inheritdoc/>
T IPrompt<T>.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<T>(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];
}
}
}

View File

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="SelectionPrompt{T}"/>.
/// </summary>
public static class SelectionPromptExtensions
{
/// <summary>
/// Adds a choice.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choice">The choice to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> AddChoice<T>(this SelectionPrompt<T> obj, T choice)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.Add(choice);
return obj;
}
/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, params T[] choices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.AddRange(choices);
return obj;
}
/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, IEnumerable<T> choices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Choices.AddRange(choices);
return obj;
}
/// <summary>
/// Sets the title.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="title">The title markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> Title<T>(this SelectionPrompt<T> obj, string? title)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Title = title;
return obj;
}
/// <summary>
/// Sets how many choices that are displayed to the user.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="pageSize">The number of choices that are displayed to the user.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> PageSize<T>(this SelectionPrompt<T> 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;
}
/// <summary>
/// Sets the function to create a display string for a given choice.
/// </summary>
/// <typeparam name="T">The prompt type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="displaySelector">The function to get a display string for a given choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> UseConverter<T>(this SelectionPrompt<T> obj, Func<T, string>? displaySelector)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
obj.Converter = displaySelector;
return obj;
}
}
}