From 315a52f3e93b62a550aef64b9333ce12f126f801 Mon Sep 17 00:00:00 2001 From: Patrik Svensson <patrik@patriksvensson.se> Date: Sun, 16 May 2021 22:45:58 +0200 Subject: [PATCH] Add support for hierarchical list prompts Closes #412 --- examples/Console/Prompt/Program.cs | 13 +- .../AcceptChoice.Output.verified.txt | 0 ...AutoComplete_BestMatch.Output.verified.txt | 0 .../AutoComplete_Empty.Output.verified.txt | 0 ...utoComplete_NextChoice.Output.verified.txt | 0 .../ConversionError.Output.verified.txt | 0 .../CustomConverter.Output.verified.txt | 0 .../CustomValidation.Output.verified.txt | 0 .../DefaultValue.Output.verified.txt | 0 .../InvalidChoice.Output.verified.txt | 0 .../SecretDefaultValue.Output.verified.txt | 0 .../{PromptTests.cs => TextPromptTests.cs} | 4 +- .../Extensions/EnumerableExtensions.cs | 17 ++ .../Rendering/RenderHookScope.cs | 1 + .../Widgets/Prompt/IMultiSelectionItem.cs | 16 ++ .../Widgets/Prompt/ISelectionItem.cs | 17 ++ .../Prompt/List/IListPromptStrategy.cs | 41 +++ .../Widgets/Prompt/List/ListPrompt.cs | 110 ++++++++ .../Prompt/List/ListPromptConstants.cs | 12 + .../Prompt/List/ListPromptInputResult.cs | 10 + .../Widgets/Prompt/List/ListPromptItem.cs | 80 ++++++ .../Prompt/List/ListPromptRenderHook.cs | 59 ++++ .../Widgets/Prompt/List/ListPromptState.cs | 46 ++++ .../Widgets/Prompt/List/ListPromptTree.cs | 40 +++ .../Widgets/Prompt/MultiSelectionPrompt.cs | 255 ++++++++++++------ .../Prompt/MultiSelectionPromptExtensions.cs | 187 ++++++++----- .../Prompt/Rendering/RenderableList.cs | 127 --------- .../Rendering/RenderableMultiSelectionList.cs | 113 -------- .../Rendering/RenderableSelectionList.cs | 84 ------ .../Widgets/Prompt/SelectionPrompt.cs | 183 +++++++++---- .../Prompt/SelectionPromptExtensions.cs | 53 +++- .../Widgets/Prompt/SelectionType.cs | 19 ++ 32 files changed, 946 insertions(+), 541 deletions(-) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AcceptChoice.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_BestMatch.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_Empty.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_NextChoice.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/ConversionError.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/CustomConverter.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/CustomValidation.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/DefaultValue.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/InvalidChoice.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/SecretDefaultValue.Output.verified.txt (100%) rename src/Spectre.Console.Tests/Unit/{PromptTests.cs => TextPromptTests.cs} (98%) create mode 100644 src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs create mode 100644 src/Spectre.Console/Widgets/Prompt/SelectionType.cs diff --git a/examples/Console/Prompt/Program.cs b/examples/Console/Prompt/Program.cs index d9a0ac9..5c79041 100644 --- a/examples/Console/Prompt/Program.cs +++ b/examples/Console/Prompt/Program.cs @@ -59,13 +59,18 @@ namespace Spectre.Console.Examples .Title("What are your [green]favorite fruits[/]?") .MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]") .InstructionsText("[grey](Press [blue]<space>[/] to toggle a fruit, [green]<enter>[/] to accept)[/]") + .AddChoiceGroup("Berries", new[] + { + "Blackcurrant", "Blueberry", "Cloudberry", + "Elderberry", "Honeyberry", "Mulberry" + }) .AddChoices(new[] { - "Apple", "Apricot", "Avocado", "Banana", "Blackcurrant", "Blueberry", - "Cherry", "Cloudberry", "Cocunut", "Date", "Dragonfruit", "Durian", - "Egg plant", "Elderberry", "Fig", "Grape", "Guava", "Honeyberry", + "Apple", "Apricot", "Avocado", "Banana", + "Cherry", "Cocunut", "Date", "Dragonfruit", "Durian", + "Egg plant", "Fig", "Grape", "Guava", "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; diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AcceptChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AcceptChoice.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AcceptChoice.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AcceptChoice.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_BestMatch.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_BestMatch.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_BestMatch.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_BestMatch.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_Empty.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_Empty.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_Empty.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_Empty.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_NextChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_NextChoice.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_NextChoice.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_NextChoice.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/ConversionError.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/ConversionError.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/ConversionError.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/ConversionError.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomConverter.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomConverter.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomConverter.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomConverter.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomValidation.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomValidation.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomValidation.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomValidation.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/DefaultValue.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/DefaultValue.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/DefaultValue.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/DefaultValue.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/InvalidChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/InvalidChoice.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/InvalidChoice.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/InvalidChoice.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/SecretDefaultValue.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/SecretDefaultValue.Output.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/SecretDefaultValue.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/SecretDefaultValue.Output.verified.txt diff --git a/src/Spectre.Console.Tests/Unit/PromptTests.cs b/src/Spectre.Console.Tests/Unit/TextPromptTests.cs similarity index 98% rename from src/Spectre.Console.Tests/Unit/PromptTests.cs rename to src/Spectre.Console.Tests/Unit/TextPromptTests.cs index 02ce5c3..1f28fad 100644 --- a/src/Spectre.Console.Tests/Unit/PromptTests.cs +++ b/src/Spectre.Console.Tests/Unit/TextPromptTests.cs @@ -9,8 +9,8 @@ using Spectre.Verify.Extensions; namespace Spectre.Console.Tests.Unit { [UsesVerify] - [ExpectationPath("Widgets/Prompt")] - public sealed class PromptTests + [ExpectationPath("Widgets/Prompt/Text")] + public sealed class TextPromptTests { [Fact] [Expectation("ConversionError")] diff --git a/src/Spectre.Console/Extensions/EnumerableExtensions.cs b/src/Spectre.Console/Extensions/EnumerableExtensions.cs index 87b754f..3dec660 100644 --- a/src/Spectre.Console/Extensions/EnumerableExtensions.cs +++ b/src/Spectre.Console/Extensions/EnumerableExtensions.cs @@ -6,6 +6,23 @@ namespace Spectre.Console { 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) { while (count-- > 0) diff --git a/src/Spectre.Console/Rendering/RenderHookScope.cs b/src/Spectre.Console/Rendering/RenderHookScope.cs index c646bfb..e6e7e6a 100644 --- a/src/Spectre.Console/Rendering/RenderHookScope.cs +++ b/src/Spectre.Console/Rendering/RenderHookScope.cs @@ -19,6 +19,7 @@ namespace Spectre.Console.Rendering { _console = console ?? throw new ArgumentNullException(nameof(console)); _hook = hook ?? throw new ArgumentNullException(nameof(hook)); + _console.Pipeline.Attach(_hook); } diff --git a/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs b/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs new file mode 100644 index 0000000..c587adc --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs @@ -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(); + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs b/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs new file mode 100644 index 0000000..ffe189d --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs @@ -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); + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs b/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs new file mode 100644 index 0000000..54614c0 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs @@ -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); + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs new file mode 100644 index 0000000..886c593 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs @@ -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))); + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs new file mode 100644 index 0000000..7a1f6d7 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs @@ -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)[/]"; + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs new file mode 100644 index 0000000..0cdc00e --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs @@ -0,0 +1,10 @@ +namespace Spectre.Console +{ + internal enum ListPromptInputResult + { + None = 0, + Refresh = 1, + Submit = 2, + Abort = 3, + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs new file mode 100644 index 0000000..c7e321b --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs @@ -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; + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs new file mode 100644 index 0000000..6b619f3 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs @@ -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; + } + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs new file mode 100644 index 0000000..0d1fa42 --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs @@ -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; + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs new file mode 100644 index 0000000..82ef5ca --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs @@ -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); + } + } + } + } + } +} diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs index 18696f9..12e4f23 100644 --- a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs @@ -7,32 +7,19 @@ using Spectre.Console.Rendering; namespace Spectre.Console { /// <summary> - /// Represents a list prompt. + /// Represents a multi selection list prompt. /// </summary> /// <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> /// Gets or sets the title. /// </summary> 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> /// Gets or sets the page size. /// Defaults to <c>10</c>. @@ -44,6 +31,18 @@ namespace Spectre.Console /// </summary> 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> /// Gets or sets the text that will be displayed if there are more choices to show. /// </summary> @@ -55,89 +54,183 @@ namespace Spectre.Console public string? InstructionsText { get; set; } /// <summary> - /// Gets or sets a value indicating whether or not - /// at least one selection is required. + /// Gets or sets the selection mode. + /// Defaults to <see cref="SelectionMode.Leaf"/>. /// </summary> - public bool Required { get; set; } = true; + public SelectionMode Mode { get; set; } = SelectionMode.Leaf; /// <summary> /// Initializes a new instance of the <see cref="MultiSelectionPrompt{T}"/> class. /// </summary> public MultiSelectionPrompt() { - Choices = new List<T>(); - Selected = new HashSet<int>(); + _tree = new ListPromptTree<T>(); + } + + /// <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/> 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) - { - throw new NotSupportedException( - "Cannot show multi selection prompt since the current " + - "terminal isn't interactive."); - } + return result.Items + .Where(x => x.Selected) + .Select(x => x.Data) + .ToList(); + } - if (!console.Profile.Capabilities.Ansi) + /// <inheritdoc/> + ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state) + { + if (key.Key == ConsoleKey.Enter) { - 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)) + if (Required && state.Items.None(x => x.Selected)) { - 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(); - } - } + // Selection not permitted + return ListPromptInputResult.None; } - list.Clear(); - console.Cursor.Show(); + // Submit + return ListPromptInputResult.Submit; + } - return list.Selections - .Select(index => Choices[index]) - .ToList(); - }); + if (key.Key == ConsoleKey.Spacebar) + { + var current = state.Items[state.Index]; + var select = !current.Selected; + + if (Mode == SelectionMode.Leaf) + { + // Select the node and all it's children + foreach (var item in current.Traverse(includeSelf: true)) + { + item.Selected = select; + } + + // Visit every parent and evaluate if it's selection + // 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; + } + + // Refresh the list + return ListPromptInputResult.Refresh; + } + + 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); } } -} \ No newline at end of file +} diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs index ee2c6c8..4575ba6 100644 --- a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs +++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs @@ -9,20 +9,48 @@ namespace Spectre.Console public static class MultiSelectionPromptExtensions { /// <summary> - /// Adds a choice. + /// Sets the selection mode. /// </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="mode">The selection mode.</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) + public static MultiSelectionPrompt<T> Mode<T>(this MultiSelectionPrompt<T> obj, SelectionMode mode) + where T : notnull { if (obj is null) { 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; } @@ -34,78 +62,16 @@ namespace Spectre.Console /// <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) + where T : notnull { if (obj is null) { throw new ArgumentNullException(nameof(obj)); } - obj.Choices.AddRange(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) + foreach (var choice in choices) { - 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); + obj.AddChoice(choice); } return obj; @@ -119,13 +85,85 @@ namespace Spectre.Console /// <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) + where T : notnull { if (obj is null) { 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; } @@ -137,6 +175,7 @@ namespace Spectre.Console /// <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) + where T : notnull { 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> /// <returns>The same instance so that multiple calls can be chained.</returns> public static MultiSelectionPrompt<T> PageSize<T>(this MultiSelectionPrompt<T> obj, int pageSize) + where T : notnull { if (obj is null) { @@ -178,6 +218,7 @@ namespace Spectre.Console /// <param name="highlightStyle">The highlight style of the selected choice.</param> /// <returns>The same instance so that multiple calls can be chained.</returns> public static MultiSelectionPrompt<T> HighlightStyle<T>(this MultiSelectionPrompt<T> obj, Style highlightStyle) + where T : notnull { if (obj is null) { @@ -196,6 +237,7 @@ namespace Spectre.Console /// <param name="text">The text to display.</param> /// <returns>The same instance so that multiple calls can be chained.</returns> public static MultiSelectionPrompt<T> MoreChoicesText<T>(this MultiSelectionPrompt<T> obj, string? text) + where T : notnull { if (obj is null) { @@ -214,6 +256,7 @@ namespace Spectre.Console /// <param name="text">The text to display.</param> /// <returns>The same instance so that multiple calls can be chained.</returns> public static MultiSelectionPrompt<T> InstructionsText<T>(this MultiSelectionPrompt<T> obj, string? text) + where T : notnull { if (obj is null) { @@ -231,6 +274,7 @@ namespace Spectre.Console /// <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) + where T : notnull { return Required(obj, false); } @@ -242,6 +286,7 @@ namespace Spectre.Console /// <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) + where T : notnull { 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> /// <returns>The same instance so that multiple calls can be chained.</returns> public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj, bool required) + where T : notnull { 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> /// <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) + where T : notnull { if (obj is null) { diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs deleted file mode 100644 index 13a327d..0000000 --- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs +++ /dev/null @@ -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))))); - } - } -} diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs deleted file mode 100644 index f28ace1..0000000 --- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs +++ /dev/null @@ -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); - } - } -} \ No newline at end of file diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs deleted file mode 100644 index 552aab5..0000000 --- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs +++ /dev/null @@ -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); - } - } -} diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs index 3c6672c..8fb1b63 100644 --- a/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs +++ b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs @@ -6,27 +6,19 @@ using Spectre.Console.Rendering; namespace Spectre.Console { /// <summary> - /// Represents a list prompt. + /// Represents a single list prompt. /// </summary> /// <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> /// 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>. @@ -38,68 +30,151 @@ namespace Spectre.Console /// </summary> 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> /// Gets or sets the text that will be displayed if there are more choices to show. /// </summary> 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> /// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class. /// </summary> 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/> - T IPrompt<T>.Show(IAnsiConsole console) + public T Show(IAnsiConsole console) { - if (!console.Profile.Capabilities.Interactive) - { - throw new NotSupportedException( - "Cannot show selection prompt since the current " + - "terminal isn't interactive."); - } + // Create the list prompt + var prompt = new ListPrompt<T>(console, this); + var result = prompt.Show(_tree); - if (!console.Profile.Capabilities.Ansi) - { - throw new NotSupportedException( - "Cannot show selection prompt since the current " + - "terminal does not support ANSI escape sequences."); - } + // Return the selected item + return result.Items[result.Index].Data; + } - return console.RunExclusive(() => + /// <inheritdoc/> + ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state) + { + if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar) { - var converter = Converter ?? TypeConverterHelper.ConvertToString; - var list = new RenderableSelectionList<T>( - console, Title, PageSize, Choices, - converter, HighlightStyle, MoreChoicesText); - - using (new RenderHookScope(console, list)) + // Selecting a non leaf in "leaf mode" is not allowed + if (state.Current.IsGroup && Mode == SelectionMode.Leaf) { - 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(); - } - } + return ListPromptInputResult.None; } - list.Clear(); - console.Cursor.Show(); + return ListPromptInputResult.Submit; + } - 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); } } } diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs index 80d1f26..bcd2117 100644 --- a/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs @@ -9,20 +9,21 @@ namespace Spectre.Console public static class SelectionPromptExtensions { /// <summary> - /// Adds a choice. + /// Sets the selection mode. /// </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="mode">The selection mode.</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) + public static SelectionPrompt<T> Mode<T>(this SelectionPrompt<T> obj, SelectionMode mode) + where T : notnull { if (obj is null) { throw new ArgumentNullException(nameof(obj)); } - obj.Choices.Add(choice); + obj.Mode = mode; return obj; } @@ -34,13 +35,18 @@ namespace Spectre.Console /// <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) + where T : notnull { if (obj is null) { throw new ArgumentNullException(nameof(obj)); } - obj.Choices.AddRange(choices); + foreach (var choice in choices) + { + obj.AddChoice(choice); + } + return obj; } @@ -52,13 +58,43 @@ namespace Spectre.Console /// <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) + where T : notnull { if (obj is null) { 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; } @@ -70,6 +106,7 @@ namespace Spectre.Console /// <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) + where T : notnull { 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> /// <returns>The same instance so that multiple calls can be chained.</returns> public static SelectionPrompt<T> PageSize<T>(this SelectionPrompt<T> obj, int pageSize) + where T : notnull { if (obj is null) { @@ -111,6 +149,7 @@ namespace Spectre.Console /// <param name="highlightStyle">The highlight style of the selected choice.</param> /// <returns>The same instance so that multiple calls can be chained.</returns> public static SelectionPrompt<T> HighlightStyle<T>(this SelectionPrompt<T> obj, Style highlightStyle) + where T : notnull { if (obj is null) { @@ -129,6 +168,7 @@ namespace Spectre.Console /// <param name="text">The text to display.</param> /// <returns>The same instance so that multiple calls can be chained.</returns> public static SelectionPrompt<T> MoreChoicesText<T>(this SelectionPrompt<T> obj, string? text) + where T : notnull { 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> /// <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) + where T : notnull { if (obj is null) { diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionType.cs b/src/Spectre.Console/Widgets/Prompt/SelectionType.cs new file mode 100644 index 0000000..963888a --- /dev/null +++ b/src/Spectre.Console/Widgets/Prompt/SelectionType.cs @@ -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, + } +}