Add support for hierarchical list prompts

Closes #412
This commit is contained in:
Patrik Svensson 2021-05-16 22:45:58 +02:00 committed by Phil Scott
parent c147929f16
commit 315a52f3e9
32 changed files with 946 additions and 541 deletions

View File

@ -59,13 +59,18 @@ namespace Spectre.Console.Examples
.Title("What are your [green]favorite fruits[/]?") .Title("What are your [green]favorite fruits[/]?")
.MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]") .MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]")
.InstructionsText("[grey](Press [blue]<space>[/] to toggle a fruit, [green]<enter>[/] to accept)[/]") .InstructionsText("[grey](Press [blue]<space>[/] to toggle a fruit, [green]<enter>[/] to accept)[/]")
.AddChoiceGroup("Berries", new[]
{
"Blackcurrant", "Blueberry", "Cloudberry",
"Elderberry", "Honeyberry", "Mulberry"
})
.AddChoices(new[] .AddChoices(new[]
{ {
"Apple", "Apricot", "Avocado", "Banana", "Blackcurrant", "Blueberry", "Apple", "Apricot", "Avocado", "Banana",
"Cherry", "Cloudberry", "Cocunut", "Date", "Dragonfruit", "Durian", "Cherry", "Cocunut", "Date", "Dragonfruit", "Durian",
"Egg plant", "Elderberry", "Fig", "Grape", "Guava", "Honeyberry", "Egg plant", "Fig", "Grape", "Guava",
"Jackfruit", "Jambul", "Kiwano", "Kiwifruit", "Lime", "Lylo", "Jackfruit", "Jambul", "Kiwano", "Kiwifruit", "Lime", "Lylo",
"Lychee", "Melon", "Mulberry", "Nectarine", "Orange", "Olive" "Lychee", "Melon", "Nectarine", "Orange", "Olive"
})); }));
var fruit = favorites.Count == 1 ? favorites[0] : null; var fruit = favorites.Count == 1 ? favorites[0] : null;

View File

@ -9,8 +9,8 @@ using Spectre.Verify.Extensions;
namespace Spectre.Console.Tests.Unit namespace Spectre.Console.Tests.Unit
{ {
[UsesVerify] [UsesVerify]
[ExpectationPath("Widgets/Prompt")] [ExpectationPath("Widgets/Prompt/Text")]
public sealed class PromptTests public sealed class TextPromptTests
{ {
[Fact] [Fact]
[Expectation("ConversionError")] [Expectation("ConversionError")]

View File

@ -6,6 +6,23 @@ namespace Spectre.Console
{ {
internal static class EnumerableExtensions internal static class EnumerableExtensions
{ {
// List.Reverse clashes with IEnumerable<T>.Reverse, so this method only exists
// so we won't have to cast List<T> to IEnumerable<T>.
public static IEnumerable<T> ReverseEnumerable<T>(this IEnumerable<T> source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
return source.Reverse();
}
public static bool None<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
return !source.Any(predicate);
}
public static IEnumerable<T> Repeat<T>(this IEnumerable<T> source, int count) public static IEnumerable<T> Repeat<T>(this IEnumerable<T> source, int count)
{ {
while (count-- > 0) while (count-- > 0)

View File

@ -19,6 +19,7 @@ namespace Spectre.Console.Rendering
{ {
_console = console ?? throw new ArgumentNullException(nameof(console)); _console = console ?? throw new ArgumentNullException(nameof(console));
_hook = hook ?? throw new ArgumentNullException(nameof(hook)); _hook = hook ?? throw new ArgumentNullException(nameof(hook));
_console.Pipeline.Attach(_hook); _console.Pipeline.Attach(_hook);
} }

View File

@ -0,0 +1,16 @@
namespace Spectre.Console
{
/// <summary>
/// Represent a multi selection prompt item.
/// </summary>
/// <typeparam name="T">The data type.</typeparam>
public interface IMultiSelectionItem<T> : ISelectionItem<T>
where T : notnull
{
/// <summary>
/// Selects the item.
/// </summary>
/// <returns>The same instance so that multiple calls can be chained.</returns>
IMultiSelectionItem<T> Select();
}
}

View File

@ -0,0 +1,17 @@
namespace Spectre.Console
{
/// <summary>
/// Represent a selection item.
/// </summary>
/// <typeparam name="T">The data type.</typeparam>
public interface ISelectionItem<T>
where T : notnull
{
/// <summary>
/// Adds a child to the item.
/// </summary>
/// <param name="child">The child to add.</param>
/// <returns>A new <see cref="ISelectionItem{T}"/> instance representing the child.</returns>
ISelectionItem<T> AddChild(T child);
}
}

View File

@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a strategy for a list prompt.
/// </summary>
/// <typeparam name="T">The list data type.</typeparam>
internal interface IListPromptStrategy<T>
where T : notnull
{
/// <summary>
/// Handles any input received from the user.
/// </summary>
/// <param name="key">The key that was pressed.</param>
/// <param name="state">The current state.</param>
/// <returns>A result representing an action.</returns>
ListPromptInputResult HandleInput(ConsoleKeyInfo key, ListPromptState<T> state);
/// <summary>
/// Calculates the page size.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="totalItemCount">The total number of items.</param>
/// <param name="requestedPageSize">The requested number of items to show.</param>
/// <returns>The page size that should be used.</returns>
public int CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize);
/// <summary>
/// Builds a <see cref="IRenderable"/> from the current state.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="scrollable">Whether or not the list is scrollable.</param>
/// <param name="cursorIndex">The cursor index.</param>
/// <param name="items">The visible items.</param>
/// <returns>A <see cref="IRenderable"/> representing the items.</returns>
public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items);
}
}

View File

@ -0,0 +1,110 @@
using System;
using System.Linq;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class ListPrompt<T>
where T : notnull
{
private readonly IAnsiConsole _console;
private readonly IListPromptStrategy<T> _strategy;
public ListPrompt(IAnsiConsole console, IListPromptStrategy<T> strategy)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_strategy = strategy ?? throw new ArgumentNullException(nameof(strategy));
}
public ListPromptState<T> Show(ListPromptTree<T> tree, int requestedPageSize = 15)
{
if (tree is null)
{
throw new ArgumentNullException(nameof(tree));
}
if (!_console.Profile.Capabilities.Interactive)
{
throw new NotSupportedException(
"Cannot show selection prompt since the current " +
"terminal isn't interactive.");
}
if (!_console.Profile.Capabilities.Ansi)
{
throw new NotSupportedException(
"Cannot show selection prompt since the current " +
"terminal does not support ANSI escape sequences.");
}
var nodes = tree.Traverse().ToList();
var state = new ListPromptState<T>(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize));
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));
using (new RenderHookScope(_console, hook))
{
_console.Cursor.Hide();
hook.Refresh();
while (true)
{
var key = _console.Input.ReadKey(true);
var result = _strategy.HandleInput(key, state);
if (result == ListPromptInputResult.Submit)
{
break;
}
if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
{
hook.Refresh();
}
}
}
hook.Clear();
_console.Cursor.Show();
return state;
}
private IRenderable BuildRenderable(ListPromptState<T> state)
{
var pageSize = state.PageSize;
var middleOfList = pageSize / 2;
var skip = 0;
var take = state.ItemCount;
var cursorIndex = state.Index;
var scrollable = state.ItemCount > pageSize;
if (scrollable)
{
skip = Math.Max(0, state.Index - middleOfList);
take = Math.Min(pageSize, state.ItemCount - skip);
if (state.ItemCount - state.Index < middleOfList)
{
// Pointer should be below the end of the list
var diff = middleOfList - (state.ItemCount - state.Index);
skip -= diff;
take += diff;
cursorIndex = middleOfList + diff;
}
else
{
// Take skip into account
cursorIndex -= skip;
}
}
// Build the renderable
return _strategy.Render(
_console,
scrollable, cursorIndex,
state.Items.Skip(skip).Take(take)
.Select((node, index) => (index, node)));
}
}
}

View File

@ -0,0 +1,12 @@
namespace Spectre.Console
{
internal sealed class ListPromptConstants
{
public const string Arrow = ">";
public const string Checkbox = "[[ ]]";
public const string SelectedCheckbox = "[[[blue]X[/]]]";
public const string GroupSelectedCheckbox = "[[[grey]X[/]]]";
public const string InstructionsMarkup = "[grey](Press <space> to select, <enter> to accept)[/]";
public const string MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]";
}
}

View File

@ -0,0 +1,10 @@
namespace Spectre.Console
{
internal enum ListPromptInputResult
{
None = 0,
Refresh = 1,
Submit = 2,
Abort = 3,
}
}

View File

@ -0,0 +1,80 @@
using System.Collections.Generic;
namespace Spectre.Console
{
internal sealed class ListPromptItem<T> : IMultiSelectionItem<T>
where T : notnull
{
public T Data { get; }
public ListPromptItem<T>? Parent { get; }
public List<ListPromptItem<T>> Children { get; }
public int Depth { get; }
public bool Selected { get; set; }
public bool IsGroup => Children.Count > 0;
public ListPromptItem(T data, ListPromptItem<T>? parent = null)
{
Data = data;
Parent = parent;
Children = new List<ListPromptItem<T>>();
Depth = CalculateDepth(parent);
}
public IMultiSelectionItem<T> Select()
{
Selected = true;
return this;
}
public ISelectionItem<T> AddChild(T item)
{
var node = new ListPromptItem<T>(item, this);
Children.Add(node);
return node;
}
public IEnumerable<ListPromptItem<T>> Traverse(bool includeSelf)
{
var stack = new Stack<ListPromptItem<T>>();
if (includeSelf)
{
stack.Push(this);
}
else
{
foreach (var child in Children)
{
stack.Push(child);
}
}
while (stack.Count > 0)
{
var current = stack.Pop();
yield return current;
if (current.Children.Count > 0)
{
foreach (var child in current.Children.ReverseEnumerable())
{
stack.Push(child);
}
}
}
}
private static int CalculateDepth(ListPromptItem<T>? parent)
{
var level = 0;
while (parent != null)
{
level++;
parent = parent.Parent;
}
return level;
}
}
}

View File

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class ListPromptRenderHook<T> : IRenderHook
where T : notnull
{
private readonly LiveRenderable _live;
private readonly object _lock;
private readonly IAnsiConsole _console;
private readonly Func<IRenderable> _builder;
private bool _dirty;
public ListPromptRenderHook(
IAnsiConsole console,
Func<IRenderable> builder)
{
_live = new LiveRenderable();
_lock = new object();
_console = console;
_builder = builder;
_dirty = true;
}
public void Clear()
{
_console.Write(_live.RestoreCursor());
}
public void Refresh()
{
_dirty = true;
_console.Write(new ControlCode(string.Empty));
}
public IEnumerable<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> renderables)
{
lock (_lock)
{
if (!_live.HasRenderable || _dirty)
{
_live.SetRenderable(_builder());
_dirty = false;
}
yield return _live.PositionCursor();
foreach (var renderable in renderables)
{
yield return renderable;
}
yield return _live;
}
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace Spectre.Console
{
internal sealed class ListPromptState<T>
where T : notnull
{
public int Index { get; private set; }
public int ItemCount => Items.Count;
public int PageSize { get; }
public IReadOnlyList<ListPromptItem<T>> Items { get; }
public ListPromptItem<T> Current => Items[Index];
public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize)
{
Index = 0;
Items = items;
PageSize = pageSize;
}
public bool Update(ConsoleKey key)
{
var index = key switch
{
ConsoleKey.UpArrow => Index - 1,
ConsoleKey.DownArrow => Index + 1,
ConsoleKey.Home => 0,
ConsoleKey.End => ItemCount - 1,
ConsoleKey.PageUp => Index - PageSize,
ConsoleKey.PageDown => Index + PageSize,
_ => Index,
};
index = index.Clamp(0, ItemCount - 1);
if (index != Index)
{
Index = index;
return true;
}
return false;
}
}
}

View File

@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace Spectre.Console
{
internal sealed class ListPromptTree<T>
where T : notnull
{
private readonly List<ListPromptItem<T>> _roots;
public ListPromptTree()
{
_roots = new List<ListPromptItem<T>>();
}
public void Add(ListPromptItem<T> node)
{
_roots.Add(node);
}
public IEnumerable<ListPromptItem<T>> Traverse()
{
foreach (var root in _roots)
{
var stack = new Stack<ListPromptItem<T>>();
stack.Push(root);
while (stack.Count > 0)
{
var current = stack.Pop();
yield return current;
foreach (var child in current.Children.ReverseEnumerable())
{
stack.Push(child);
}
}
}
}
}
}

View File

@ -7,32 +7,19 @@ using Spectre.Console.Rendering;
namespace Spectre.Console namespace Spectre.Console
{ {
/// <summary> /// <summary>
/// Represents a list prompt. /// Represents a multi selection list prompt.
/// </summary> /// </summary>
/// <typeparam name="T">The prompt result type.</typeparam> /// <typeparam name="T">The prompt result type.</typeparam>
public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>> public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrategy<T>
where T : notnull
{ {
private readonly ListPromptTree<T> _tree;
/// <summary> /// <summary>
/// Gets or sets the title. /// Gets or sets the title.
/// </summary> /// </summary>
public string? Title { get; set; } public string? Title { get; set; }
/// <summary>
/// Gets the choices.
/// </summary>
public List<T> Choices { get; }
/// <summary>
/// Gets the initially selected choices.
/// </summary>
public HashSet<int> Selected { 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> /// <summary>
/// Gets or sets the page size. /// Gets or sets the page size.
/// Defaults to <c>10</c>. /// Defaults to <c>10</c>.
@ -44,6 +31,18 @@ namespace Spectre.Console
/// </summary> /// </summary>
public Style? HighlightStyle { get; set; } public Style? HighlightStyle { get; set; }
/// <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; }
/// <summary>
/// Gets or sets a value indicating whether or not
/// at least one selection is required.
/// </summary>
public bool Required { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets the text that will be displayed if there are more choices to show. /// Gets or sets the text that will be displayed if there are more choices to show.
/// </summary> /// </summary>
@ -55,89 +54,183 @@ namespace Spectre.Console
public string? InstructionsText { get; set; } public string? InstructionsText { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether or not /// Gets or sets the selection mode.
/// at least one selection is required. /// Defaults to <see cref="SelectionMode.Leaf"/>.
/// </summary> /// </summary>
public bool Required { get; set; } = true; public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MultiSelectionPrompt{T}"/> class. /// Initializes a new instance of the <see cref="MultiSelectionPrompt{T}"/> class.
/// </summary> /// </summary>
public MultiSelectionPrompt() public MultiSelectionPrompt()
{ {
Choices = new List<T>(); _tree = new ListPromptTree<T>();
Selected = new HashSet<int>(); }
/// <summary>
/// Adds a choice.
/// </summary>
/// <param name="item">The item to add.</param>
/// <returns>A <see cref="IMultiSelectionItem{T}"/> so that multiple calls can be chained.</returns>
public IMultiSelectionItem<T> AddChoice(T item)
{
var node = new ListPromptItem<T>(item);
_tree.Add(node);
return node;
} }
/// <inheritdoc/> /// <inheritdoc/>
public List<T> Show(IAnsiConsole console) public List<T> Show(IAnsiConsole console)
{ {
if (console is null) // Create the list prompt
var prompt = new ListPrompt<T>(console, this);
var result = prompt.Show(_tree, PageSize);
if (Mode == SelectionMode.Leaf)
{ {
throw new ArgumentNullException(nameof(console)); return result.Items
.Where(x => x.Selected && x.Children.Count == 0)
.Select(x => x.Data)
.ToList();
} }
if (!console.Profile.Capabilities.Interactive) return result.Items
{ .Where(x => x.Selected)
throw new NotSupportedException( .Select(x => x.Data)
"Cannot show multi selection prompt since the current " + .ToList();
"terminal isn't interactive.");
} }
if (!console.Profile.Capabilities.Ansi) /// <inheritdoc/>
ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state)
{ {
throw new NotSupportedException(
"Cannot show multi selection prompt since the current " +
"terminal does not support ANSI escape sequences.");
}
return console.RunExclusive(() =>
{
var converter = Converter ?? TypeConverterHelper.ConvertToString;
var list = new RenderableMultiSelectionList<T>(
console, Title, PageSize, Choices,
Selected, converter, HighlightStyle,
MoreChoicesText, InstructionsText);
using (new RenderHookScope(console, list))
{
console.Cursor.Hide();
list.Redraw();
while (true)
{
var key = console.Input.ReadKey(true);
if (key.Key == ConsoleKey.Enter) if (key.Key == ConsoleKey.Enter)
{ {
if (Required && list.Selections.Count == 0) if (Required && state.Items.None(x => x.Selected))
{ {
continue; // Selection not permitted
return ListPromptInputResult.None;
} }
break; // Submit
return ListPromptInputResult.Submit;
} }
if (key.Key == ConsoleKey.Spacebar) if (key.Key == ConsoleKey.Spacebar)
{ {
list.Select(); var current = state.Items[state.Index];
list.Redraw(); var select = !current.Selected;
continue;
}
if (list.Update(key.Key)) if (Mode == SelectionMode.Leaf)
{ {
list.Redraw(); // Select the node and all it's children
} foreach (var item in current.Traverse(includeSelf: true))
} {
item.Selected = select;
} }
list.Clear(); // Visit every parent and evaluate if it's selection
console.Cursor.Show(); // status need to be updated
var parent = current.Parent;
while (parent != null)
{
parent.Selected = parent.Traverse(includeSelf: false).All(x => x.Selected);
parent = parent.Parent;
}
}
else
{
current.Selected = !current.Selected;
}
return list.Selections // Refresh the list
.Select(index => Choices[index]) return ListPromptInputResult.Refresh;
.ToList(); }
});
return ListPromptInputResult.None;
}
/// <inheritdoc/>
int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize)
{
// The instructions take up two rows including a blank line
var extra = 2;
if (Title != null)
{
// Title takes up two rows including a blank line
extra += 2;
}
// Scrolling?
if (totalItemCount > requestedPageSize)
{
// The scrolling instructions takes up one row
extra++;
}
var pageSize = requestedPageSize;
if (pageSize > console.Profile.Height - extra)
{
pageSize = console.Profile.Height - extra;
}
return pageSize;
}
/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
{
var list = new List<IRenderable>();
var highlightStyle = HighlightStyle ?? new Style(foreground: Color.Blue);
if (Title != null)
{
list.Add(new Markup(Title));
}
var grid = new Grid();
grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
if (Title != null)
{
grid.AddEmptyRow();
}
foreach (var item in items)
{
var current = item.Index == cursorIndex;
var style = current ? highlightStyle : Style.Plain;
var indent = new string(' ', item.Node.Depth * 2);
var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length);
var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?";
if (current)
{
text = text.RemoveMarkup();
}
var checkbox = item.Node.Selected
? (item.Node.IsGroup && Mode == SelectionMode.Leaf
? ListPromptConstants.GroupSelectedCheckbox : ListPromptConstants.SelectedCheckbox)
: ListPromptConstants.Checkbox;
grid.AddRow(new Markup(indent + prompt + " " + checkbox + " " + text, style));
}
list.Add(grid);
list.Add(Text.Empty);
if (scrollable)
{
// There are more choices
list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
}
// Instructions
list.Add(new Markup(InstructionsText ?? ListPromptConstants.InstructionsMarkup));
// Combine all items
return new Rows(list);
} }
} }
} }

View File

@ -9,20 +9,48 @@ namespace Spectre.Console
public static class MultiSelectionPromptExtensions public static class MultiSelectionPromptExtensions
{ {
/// <summary> /// <summary>
/// Adds a choice. /// Sets the selection mode.
/// </summary> /// </summary>
/// <typeparam name="T">The prompt result type.</typeparam> /// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param> /// <param name="obj">The prompt.</param>
/// <param name="choice">The choice to add.</param> /// <param name="mode">The selection mode.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> AddChoice<T>(this MultiSelectionPrompt<T> obj, T choice) public static MultiSelectionPrompt<T> Mode<T>(this MultiSelectionPrompt<T> obj, SelectionMode mode)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.Add(choice); obj.Mode = mode;
return obj;
}
/// <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>
/// <param name="configurator">The configurator for the choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, T choice, Action<IMultiSelectionItem<T>> configurator)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
if (configurator is null)
{
throw new ArgumentNullException(nameof(configurator));
}
var result = obj.AddChoice(choice);
configurator(result);
return obj; return obj;
} }
@ -34,78 +62,16 @@ namespace Spectre.Console
/// <param name="choices">The choices to add.</param> /// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <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) public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, params T[] choices)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.AddRange(choices); foreach (var choice in choices)
return obj;
}
/// <summary>
/// Marks an item as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="index">The index of the item to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, int index)
{ {
if (obj is null) obj.AddChoice(choice);
{
throw new ArgumentNullException(nameof(obj));
}
if (index < 0)
{
throw new ArgumentException("Index must be greater than zero", nameof(index));
}
obj.Selected.Add(index);
return obj;
}
/// <summary>
/// Marks multiple items as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="indices">The indices of the items to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, params int[] indices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
foreach (var index in indices)
{
Select(obj, index);
}
return obj;
}
/// <summary>
/// Marks multiple items as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="indices">The indices of the items to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, IEnumerable<int> indices)
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
foreach (var index in indices)
{
Select(obj, index);
} }
return obj; return obj;
@ -119,13 +85,85 @@ namespace Spectre.Console
/// <param name="choices">The choices to add.</param> /// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <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) public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, IEnumerable<T> choices)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.AddRange(choices); foreach (var choice in choices)
{
obj.AddChoice(choice);
}
return obj;
}
/// <summary>
/// Adds multiple grouped choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="group">The group.</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> AddChoiceGroup<T>(this MultiSelectionPrompt<T> obj, T group, IEnumerable<T> choices)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
var root = obj.AddChoice(group);
foreach (var choice in choices)
{
root.AddChild(choice);
}
return obj;
}
/// <summary>
/// Marks an item as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="index">The index of the item to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
[Obsolete("Selection by index has been made obsolete", error: true)]
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, int index)
where T : notnull
{
return obj;
}
/// <summary>
/// Marks multiple items as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="indices">The indices of the items to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
[Obsolete("Selection by index has been made obsolete", error: true)]
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, params int[] indices)
where T : notnull
{
return obj;
}
/// <summary>
/// Marks multiple items as selected.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="indices">The indices of the items to select.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
[Obsolete("Selection by index has been made obsolete", error: true)]
public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, IEnumerable<int> indices)
where T : notnull
{
return obj; return obj;
} }
@ -137,6 +175,7 @@ namespace Spectre.Console
/// <param name="title">The title markup text.</param> /// <param name="title">The title markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Title<T>(this MultiSelectionPrompt<T> obj, string? title) public static MultiSelectionPrompt<T> Title<T>(this MultiSelectionPrompt<T> obj, string? title)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -155,6 +194,7 @@ namespace Spectre.Console
/// <param name="pageSize">The number of choices that are displayed to the user.</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> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> PageSize<T>(this MultiSelectionPrompt<T> obj, int pageSize) public static MultiSelectionPrompt<T> PageSize<T>(this MultiSelectionPrompt<T> obj, int pageSize)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -178,6 +218,7 @@ namespace Spectre.Console
/// <param name="highlightStyle">The highlight style of the selected choice.</param> /// <param name="highlightStyle">The highlight style of the selected choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> HighlightStyle<T>(this MultiSelectionPrompt<T> obj, Style highlightStyle) public static MultiSelectionPrompt<T> HighlightStyle<T>(this MultiSelectionPrompt<T> obj, Style highlightStyle)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -196,6 +237,7 @@ namespace Spectre.Console
/// <param name="text">The text to display.</param> /// <param name="text">The text to display.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> MoreChoicesText<T>(this MultiSelectionPrompt<T> obj, string? text) public static MultiSelectionPrompt<T> MoreChoicesText<T>(this MultiSelectionPrompt<T> obj, string? text)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -214,6 +256,7 @@ namespace Spectre.Console
/// <param name="text">The text to display.</param> /// <param name="text">The text to display.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> InstructionsText<T>(this MultiSelectionPrompt<T> obj, string? text) public static MultiSelectionPrompt<T> InstructionsText<T>(this MultiSelectionPrompt<T> obj, string? text)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -231,6 +274,7 @@ namespace Spectre.Console
/// <param name="obj">The prompt.</param> /// <param name="obj">The prompt.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> NotRequired<T>(this MultiSelectionPrompt<T> obj) public static MultiSelectionPrompt<T> NotRequired<T>(this MultiSelectionPrompt<T> obj)
where T : notnull
{ {
return Required(obj, false); return Required(obj, false);
} }
@ -242,6 +286,7 @@ namespace Spectre.Console
/// <param name="obj">The prompt.</param> /// <param name="obj">The prompt.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj) public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj)
where T : notnull
{ {
return Required(obj, true); return Required(obj, true);
} }
@ -254,6 +299,7 @@ namespace Spectre.Console
/// <param name="required">Whether or not at least one choice must be selected.</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> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj, bool required) public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj, bool required)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -272,6 +318,7 @@ namespace Spectre.Console
/// <param name="displaySelector">The function to get a display string for a given choice.</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> /// <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) public static MultiSelectionPrompt<T> UseConverter<T>(this MultiSelectionPrompt<T> obj, Func<T, string>? displaySelector)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {

View File

@ -1,127 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
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.Write(_live.RestoreCursor());
}
public void Redraw()
{
_console.Write(new ControlCode(string.Empty));
}
public bool Update(ConsoleKey key)
{
var index = key switch
{
ConsoleKey.UpArrow => _index - 1,
ConsoleKey.DownArrow => _index + 1,
ConsoleKey.Home => 0,
ConsoleKey.End => _choices.Count - 1,
ConsoleKey.PageUp => _index - CalculatePageSize(_requestedPageSize),
ConsoleKey.PageDown => _index + CalculatePageSize(_requestedPageSize),
_ => _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

@ -1,113 +0,0 @@
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 const string MoreChoicesText = "[grey](Move up and down to reveal more choices)[/]";
private const string InstructionsText = "[grey](Press <space> to select, <enter> to accept)[/]";
private readonly IAnsiConsole _console;
private readonly string? _title;
private readonly Markup _moreChoices;
private readonly Markup _instructions;
private readonly Style _highlightStyle;
public HashSet<int> Selections { get; set; }
public RenderableMultiSelectionList(
IAnsiConsole console, string? title, int pageSize,
List<T> choices, HashSet<int> selections,
Func<T, string>? converter, Style? highlightStyle,
string? moreChoicesText, string? instructionsText)
: base(console, pageSize, choices, converter)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_title = title;
_highlightStyle = highlightStyle ?? new Style(foreground: Color.Blue);
_moreChoices = new Markup(moreChoicesText ?? MoreChoicesText);
_instructions = new Markup(instructionsText ?? InstructionsText);
Selections = new HashSet<int>(selections);
}
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.Profile.Height - 5)
{
pageSize = _console.Profile.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;
var item = current
? new Text(choice.Item.RemoveMarkup(), style)
: (IRenderable)new Markup(choice.Item, style);
grid.AddRow(new Markup(prompt + checkbox, style), item);
}
list.Add(grid);
list.Add(Text.Empty);
if (scrollable)
{
// (Move up and down to reveal more choices)
list.Add(_moreChoices);
}
// (Press <space> to select)
list.Add(_instructions);
return new Rows(list);
}
}
}

View File

@ -1,84 +0,0 @@
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 const string MoreChoicesText = "[grey](Move up and down to reveal more choices)[/]";
private readonly IAnsiConsole _console;
private readonly string? _title;
private readonly Markup _moreChoices;
private readonly Style _highlightStyle;
public RenderableSelectionList(
IAnsiConsole console, string? title, int requestedPageSize,
List<T> choices, Func<T, string>? converter, Style? highlightStyle,
string? moreChoices)
: base(console, requestedPageSize, choices, converter)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_title = title;
_highlightStyle = highlightStyle ?? new Style(foreground: Color.Blue);
_moreChoices = new Markup(moreChoices ?? MoreChoicesText);
}
protected override int CalculatePageSize(int requestedPageSize)
{
var pageSize = requestedPageSize;
if (pageSize > _console.Profile.Height - 4)
{
pageSize = _console.Profile.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;
var item = current
? new Text(choice.Item.RemoveMarkup(), style)
: (IRenderable)new Markup(choice.Item, style);
grid.AddRow(new Markup(prompt, style), item);
}
list.Add(grid);
if (scrollable)
{
// (Move up and down to reveal more choices)
list.Add(Text.Empty);
list.Add(_moreChoices);
}
return new Rows(list);
}
}
}

View File

@ -6,27 +6,19 @@ using Spectre.Console.Rendering;
namespace Spectre.Console namespace Spectre.Console
{ {
/// <summary> /// <summary>
/// Represents a list prompt. /// Represents a single list prompt.
/// </summary> /// </summary>
/// <typeparam name="T">The prompt result type.</typeparam> /// <typeparam name="T">The prompt result type.</typeparam>
public sealed class SelectionPrompt<T> : IPrompt<T> public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
where T : notnull
{ {
private readonly ListPromptTree<T> _tree;
/// <summary> /// <summary>
/// Gets or sets the title. /// Gets or sets the title.
/// </summary> /// </summary>
public string? Title { get; set; } 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> /// <summary>
/// Gets or sets the page size. /// Gets or sets the page size.
/// Defaults to <c>10</c>. /// Defaults to <c>10</c>.
@ -38,68 +30,151 @@ namespace Spectre.Console
/// </summary> /// </summary>
public Style? HighlightStyle { get; set; } public Style? HighlightStyle { get; set; }
/// <summary>
/// Gets or sets the style of a disabled choice.
/// </summary>
public Style? DisabledStyle { get; set; }
/// <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; }
/// <summary> /// <summary>
/// Gets or sets the text that will be displayed if there are more choices to show. /// Gets or sets the text that will be displayed if there are more choices to show.
/// </summary> /// </summary>
public string? MoreChoicesText { get; set; } public string? MoreChoicesText { get; set; }
/// <summary>
/// Gets or sets the selection mode.
/// Defaults to <see cref="SelectionMode.Leaf"/>.
/// </summary>
public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class. /// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
/// </summary> /// </summary>
public SelectionPrompt() public SelectionPrompt()
{ {
Choices = new List<T>(); _tree = new ListPromptTree<T>();
}
/// <summary>
/// Adds a choice.
/// </summary>
/// <param name="item">The item to add.</param>
/// <returns>A <see cref="ISelectionItem{T}"/> so that multiple calls can be chained.</returns>
public ISelectionItem<T> AddChoice(T item)
{
var node = new ListPromptItem<T>(item);
_tree.Add(node);
return node;
} }
/// <inheritdoc/> /// <inheritdoc/>
T IPrompt<T>.Show(IAnsiConsole console) public T Show(IAnsiConsole console)
{ {
if (!console.Profile.Capabilities.Interactive) // Create the list prompt
{ var prompt = new ListPrompt<T>(console, this);
throw new NotSupportedException( var result = prompt.Show(_tree);
"Cannot show selection prompt since the current " +
"terminal isn't interactive."); // Return the selected item
return result.Items[result.Index].Data;
} }
if (!console.Profile.Capabilities.Ansi) /// <inheritdoc/>
ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state)
{ {
throw new NotSupportedException(
"Cannot show selection prompt since the current " +
"terminal does not support ANSI escape sequences.");
}
return console.RunExclusive(() =>
{
var converter = Converter ?? TypeConverterHelper.ConvertToString;
var list = new RenderableSelectionList<T>(
console, Title, PageSize, Choices,
converter, HighlightStyle, MoreChoicesText);
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) if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar)
{ {
break; // Selecting a non leaf in "leaf mode" is not allowed
} if (state.Current.IsGroup && Mode == SelectionMode.Leaf)
if (list.Update(key.Key))
{ {
list.Redraw(); return ListPromptInputResult.None;
}
}
} }
list.Clear(); return ListPromptInputResult.Submit;
console.Cursor.Show(); }
return Choices[list.Index]; return ListPromptInputResult.None;
}); }
/// <inheritdoc/>
int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize)
{
var extra = 0;
if (Title != null)
{
// Title takes up two rows including a blank line
extra += 2;
}
// Scrolling?
if (totalItemCount > requestedPageSize)
{
// The scrolling instructions takes up two rows
extra += 2;
}
if (requestedPageSize > console.Profile.Height - extra)
{
return console.Profile.Height - extra;
}
return requestedPageSize;
}
/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
{
var list = new List<IRenderable>();
var disabledStyle = DisabledStyle ?? new Style(foreground: Color.Grey);
var highlightStyle = HighlightStyle ?? new Style(foreground: Color.Blue);
if (Title != null)
{
list.Add(new Markup(Title));
}
var grid = new Grid();
grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
if (Title != null)
{
grid.AddEmptyRow();
}
foreach (var item in items)
{
var current = item.Index == cursorIndex;
var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length);
var style = item.Node.IsGroup && Mode == SelectionMode.Leaf
? disabledStyle
: current ? highlightStyle : Style.Plain;
var indent = new string(' ', item.Node.Depth * 2);
var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?";
if (current)
{
text = text.RemoveMarkup();
}
grid.AddRow(new Markup(indent + prompt + " " + text, style));
}
list.Add(grid);
if (scrollable)
{
// (Move up and down to reveal more choices)
list.Add(Text.Empty);
list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
}
return new Rows(list);
} }
} }
} }

View File

@ -9,20 +9,21 @@ namespace Spectre.Console
public static class SelectionPromptExtensions public static class SelectionPromptExtensions
{ {
/// <summary> /// <summary>
/// Adds a choice. /// Sets the selection mode.
/// </summary> /// </summary>
/// <typeparam name="T">The prompt result type.</typeparam> /// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param> /// <param name="obj">The prompt.</param>
/// <param name="choice">The choice to add.</param> /// <param name="mode">The selection mode.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> AddChoice<T>(this SelectionPrompt<T> obj, T choice) public static SelectionPrompt<T> Mode<T>(this SelectionPrompt<T> obj, SelectionMode mode)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.Add(choice); obj.Mode = mode;
return obj; return obj;
} }
@ -34,13 +35,18 @@ namespace Spectre.Console
/// <param name="choices">The choices to add.</param> /// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <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) public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, params T[] choices)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.AddRange(choices); foreach (var choice in choices)
{
obj.AddChoice(choice);
}
return obj; return obj;
} }
@ -52,13 +58,43 @@ namespace Spectre.Console
/// <param name="choices">The choices to add.</param> /// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <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) public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, IEnumerable<T> choices)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.Choices.AddRange(choices); foreach (var choice in choices)
{
obj.AddChoice(choice);
}
return obj;
}
/// <summary>
/// Adds multiple grouped choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="group">The group.</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> AddChoiceGroup<T>(this SelectionPrompt<T> obj, T group, IEnumerable<T> choices)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}
var root = obj.AddChoice(group);
foreach (var choice in choices)
{
root.AddChild(choice);
}
return obj; return obj;
} }
@ -70,6 +106,7 @@ namespace Spectre.Console
/// <param name="title">The title markup text.</param> /// <param name="title">The title markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> Title<T>(this SelectionPrompt<T> obj, string? title) public static SelectionPrompt<T> Title<T>(this SelectionPrompt<T> obj, string? title)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -88,6 +125,7 @@ namespace Spectre.Console
/// <param name="pageSize">The number of choices that are displayed to the user.</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> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> PageSize<T>(this SelectionPrompt<T> obj, int pageSize) public static SelectionPrompt<T> PageSize<T>(this SelectionPrompt<T> obj, int pageSize)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -111,6 +149,7 @@ namespace Spectre.Console
/// <param name="highlightStyle">The highlight style of the selected choice.</param> /// <param name="highlightStyle">The highlight style of the selected choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> HighlightStyle<T>(this SelectionPrompt<T> obj, Style highlightStyle) public static SelectionPrompt<T> HighlightStyle<T>(this SelectionPrompt<T> obj, Style highlightStyle)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -129,6 +168,7 @@ namespace Spectre.Console
/// <param name="text">The text to display.</param> /// <param name="text">The text to display.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns> /// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> MoreChoicesText<T>(this SelectionPrompt<T> obj, string? text) public static SelectionPrompt<T> MoreChoicesText<T>(this SelectionPrompt<T> obj, string? text)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {
@ -147,6 +187,7 @@ namespace Spectre.Console
/// <param name="displaySelector">The function to get a display string for a given choice.</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> /// <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) public static SelectionPrompt<T> UseConverter<T>(this SelectionPrompt<T> obj, Func<T, string>? displaySelector)
where T : notnull
{ {
if (obj is null) if (obj is null)
{ {

View File

@ -0,0 +1,19 @@
namespace Spectre.Console
{
/// <summary>
/// Represents how selections are made in a hierarchical prompt.
/// </summary>
public enum SelectionMode
{
/// <summary>
/// Will only return lead nodes in results.
/// </summary>
Leaf = 0,
/// <summary>
/// Allows selection of parent nodes, but each node
/// is independent of its parent and children.
/// </summary>
Independent = 1,
}
}