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][/] to toggle a fruit, [green][/] 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.Reverse, so this method only exists + // so we won't have to cast List to IEnumerable. + public static IEnumerable ReverseEnumerable(this IEnumerable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.Reverse(); + } + + public static bool None(this IEnumerable source, Func predicate) + { + return !source.Any(predicate); + } + public static IEnumerable Repeat(this IEnumerable 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 +{ + /// + /// Represent a multi selection prompt item. + /// + /// The data type. + public interface IMultiSelectionItem : ISelectionItem + where T : notnull + { + /// + /// Selects the item. + /// + /// The same instance so that multiple calls can be chained. + IMultiSelectionItem 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 +{ + /// + /// Represent a selection item. + /// + /// The data type. + public interface ISelectionItem + where T : notnull + { + /// + /// Adds a child to the item. + /// + /// The child to add. + /// A new instance representing the child. + ISelectionItem 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 +{ + /// + /// Represents a strategy for a list prompt. + /// + /// The list data type. + internal interface IListPromptStrategy + where T : notnull + { + /// + /// Handles any input received from the user. + /// + /// The key that was pressed. + /// The current state. + /// A result representing an action. + ListPromptInputResult HandleInput(ConsoleKeyInfo key, ListPromptState state); + + /// + /// Calculates the page size. + /// + /// The console. + /// The total number of items. + /// The requested number of items to show. + /// The page size that should be used. + public int CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize); + + /// + /// Builds a from the current state. + /// + /// The console. + /// Whether or not the list is scrollable. + /// The cursor index. + /// The visible items. + /// A representing the items. + public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem 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 + where T : notnull + { + private readonly IAnsiConsole _console; + private readonly IListPromptStrategy _strategy; + + public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + } + + public ListPromptState Show(ListPromptTree 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(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize)); + var hook = new ListPromptRenderHook(_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 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 to select, 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 : IMultiSelectionItem + where T : notnull + { + public T Data { get; } + public ListPromptItem? Parent { get; } + public List> Children { get; } + public int Depth { get; } + public bool Selected { get; set; } + + public bool IsGroup => Children.Count > 0; + + public ListPromptItem(T data, ListPromptItem? parent = null) + { + Data = data; + Parent = parent; + Children = new List>(); + Depth = CalculateDepth(parent); + } + + public IMultiSelectionItem Select() + { + Selected = true; + return this; + } + + public ISelectionItem AddChild(T item) + { + var node = new ListPromptItem(item, this); + Children.Add(node); + return node; + } + + public IEnumerable> Traverse(bool includeSelf) + { + var stack = new Stack>(); + + 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? 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 : IRenderHook + where T : notnull + { + private readonly LiveRenderable _live; + private readonly object _lock; + private readonly IAnsiConsole _console; + private readonly Func _builder; + private bool _dirty; + + public ListPromptRenderHook( + IAnsiConsole console, + Func 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 Process(RenderContext context, IEnumerable 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 + where T : notnull + { + public int Index { get; private set; } + public int ItemCount => Items.Count; + public int PageSize { get; } + public IReadOnlyList> Items { get; } + + public ListPromptItem Current => Items[Index]; + + public ListPromptState(IReadOnlyList> 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 + where T : notnull + { + private readonly List> _roots; + + public ListPromptTree() + { + _roots = new List>(); + } + + public void Add(ListPromptItem node) + { + _roots.Add(node); + } + + public IEnumerable> Traverse() + { + foreach (var root in _roots) + { + var stack = new Stack>(); + 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 { /// - /// Represents a list prompt. + /// Represents a multi selection list prompt. /// /// The prompt result type. - public sealed class MultiSelectionPrompt : IPrompt> + public sealed class MultiSelectionPrompt : IPrompt>, IListPromptStrategy + where T : notnull { + private readonly ListPromptTree _tree; + /// /// Gets or sets the title. /// public string? Title { get; set; } - /// - /// Gets the choices. - /// - public List Choices { get; } - - /// - /// Gets the initially selected choices. - /// - public HashSet Selected { get; } - - /// - /// Gets or sets the converter to get the display string for a choice. By default - /// the corresponding is used. - /// - public Func? Converter { get; set; } = TypeConverterHelper.ConvertToString; - /// /// Gets or sets the page size. /// Defaults to 10. @@ -44,6 +31,18 @@ namespace Spectre.Console /// public Style? HighlightStyle { get; set; } + /// + /// Gets or sets the converter to get the display string for a choice. By default + /// the corresponding is used. + /// + public Func? Converter { get; set; } + + /// + /// Gets or sets a value indicating whether or not + /// at least one selection is required. + /// + public bool Required { get; set; } = true; + /// /// Gets or sets the text that will be displayed if there are more choices to show. /// @@ -55,89 +54,183 @@ namespace Spectre.Console public string? InstructionsText { get; set; } /// - /// Gets or sets a value indicating whether or not - /// at least one selection is required. + /// Gets or sets the selection mode. + /// Defaults to . /// - public bool Required { get; set; } = true; + public SelectionMode Mode { get; set; } = SelectionMode.Leaf; /// /// Initializes a new instance of the class. /// public MultiSelectionPrompt() { - Choices = new List(); - Selected = new HashSet(); + _tree = new ListPromptTree(); + } + + /// + /// Adds a choice. + /// + /// The item to add. + /// A so that multiple calls can be chained. + public IMultiSelectionItem AddChoice(T item) + { + var node = new ListPromptItem(item); + _tree.Add(node); + return node; } /// public List Show(IAnsiConsole console) { - if (console is null) + // Create the list prompt + var prompt = new ListPrompt(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) + /// + ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState 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( - 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; + } + + /// + int IListPromptStrategy.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; + } + + /// + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + { + var list = new List(); + 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 { /// - /// Adds a choice. + /// Sets the selection mode. /// /// The prompt result type. /// The prompt. - /// The choice to add. + /// The selection mode. /// The same instance so that multiple calls can be chained. - public static MultiSelectionPrompt AddChoice(this MultiSelectionPrompt obj, T choice) + public static MultiSelectionPrompt Mode(this MultiSelectionPrompt obj, SelectionMode mode) + where T : notnull { if (obj is null) { throw new ArgumentNullException(nameof(obj)); } - obj.Choices.Add(choice); + obj.Mode = mode; + return obj; + } + + /// + /// Adds a choice. + /// + /// The prompt result type. + /// The prompt. + /// The choice to add. + /// The configurator for the choice. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt AddChoices(this MultiSelectionPrompt obj, T choice, Action> 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 /// The choices to add. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt AddChoices(this MultiSelectionPrompt obj, params T[] choices) + where T : notnull { if (obj is null) { throw new ArgumentNullException(nameof(obj)); } - obj.Choices.AddRange(choices); - return obj; - } - - /// - /// Marks an item as selected. - /// - /// The prompt result type. - /// The prompt. - /// The index of the item to select. - /// The same instance so that multiple calls can be chained. - public static MultiSelectionPrompt Select(this MultiSelectionPrompt 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; - } - - /// - /// Marks multiple items as selected. - /// - /// The prompt result type. - /// The prompt. - /// The indices of the items to select. - /// The same instance so that multiple calls can be chained. - public static MultiSelectionPrompt Select(this MultiSelectionPrompt obj, params int[] indices) - { - if (obj is null) - { - throw new ArgumentNullException(nameof(obj)); - } - - foreach (var index in indices) - { - Select(obj, index); - } - - return obj; - } - - /// - /// Marks multiple items as selected. - /// - /// The prompt result type. - /// The prompt. - /// The indices of the items to select. - /// The same instance so that multiple calls can be chained. - public static MultiSelectionPrompt Select(this MultiSelectionPrompt obj, IEnumerable 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 /// The choices to add. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt AddChoices(this MultiSelectionPrompt obj, IEnumerable 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; + } + + /// + /// Adds multiple grouped choices. + /// + /// The prompt result type. + /// The prompt. + /// The group. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static MultiSelectionPrompt AddChoiceGroup(this MultiSelectionPrompt obj, T group, IEnumerable 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; + } + + /// + /// Marks an item as selected. + /// + /// The prompt result type. + /// The prompt. + /// The index of the item to select. + /// The same instance so that multiple calls can be chained. + [Obsolete("Selection by index has been made obsolete", error: true)] + public static MultiSelectionPrompt Select(this MultiSelectionPrompt obj, int index) + where T : notnull + { + return obj; + } + + /// + /// Marks multiple items as selected. + /// + /// The prompt result type. + /// The prompt. + /// The indices of the items to select. + /// The same instance so that multiple calls can be chained. + [Obsolete("Selection by index has been made obsolete", error: true)] + public static MultiSelectionPrompt Select(this MultiSelectionPrompt obj, params int[] indices) + where T : notnull + { + return obj; + } + + /// + /// Marks multiple items as selected. + /// + /// The prompt result type. + /// The prompt. + /// The indices of the items to select. + /// The same instance so that multiple calls can be chained. + [Obsolete("Selection by index has been made obsolete", error: true)] + public static MultiSelectionPrompt Select(this MultiSelectionPrompt obj, IEnumerable indices) + where T : notnull + { return obj; } @@ -137,6 +175,7 @@ namespace Spectre.Console /// The title markup text. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt Title(this MultiSelectionPrompt obj, string? title) + where T : notnull { if (obj is null) { @@ -155,6 +194,7 @@ namespace Spectre.Console /// The number of choices that are displayed to the user. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt PageSize(this MultiSelectionPrompt obj, int pageSize) + where T : notnull { if (obj is null) { @@ -178,6 +218,7 @@ namespace Spectre.Console /// The highlight style of the selected choice. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt HighlightStyle(this MultiSelectionPrompt obj, Style highlightStyle) + where T : notnull { if (obj is null) { @@ -196,6 +237,7 @@ namespace Spectre.Console /// The text to display. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt MoreChoicesText(this MultiSelectionPrompt obj, string? text) + where T : notnull { if (obj is null) { @@ -214,6 +256,7 @@ namespace Spectre.Console /// The text to display. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt InstructionsText(this MultiSelectionPrompt obj, string? text) + where T : notnull { if (obj is null) { @@ -231,6 +274,7 @@ namespace Spectre.Console /// The prompt. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt NotRequired(this MultiSelectionPrompt obj) + where T : notnull { return Required(obj, false); } @@ -242,6 +286,7 @@ namespace Spectre.Console /// The prompt. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt Required(this MultiSelectionPrompt obj) + where T : notnull { return Required(obj, true); } @@ -254,6 +299,7 @@ namespace Spectre.Console /// Whether or not at least one choice must be selected. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt Required(this MultiSelectionPrompt obj, bool required) + where T : notnull { if (obj is null) { @@ -272,6 +318,7 @@ namespace Spectre.Console /// The function to get a display string for a given choice. /// The same instance so that multiple calls can be chained. public static MultiSelectionPrompt UseConverter(this MultiSelectionPrompt obj, Func? displaySelector) + 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 : IRenderHook - { - private readonly LiveRenderable _live; - private readonly object _lock; - private readonly IAnsiConsole _console; - private readonly int _requestedPageSize; - private readonly List _choices; - private readonly Func _converter; - private int _index; - - public int Index => _index; - - public RenderableList(IAnsiConsole console, int requestedPageSize, List choices, Func? converter) - { - _console = console; - _requestedPageSize = requestedPageSize; - _choices = choices; - _converter = converter ?? throw new ArgumentNullException(nameof(converter)); - _live = new LiveRenderable(); - _lock = new object(); - _index = 0; - } - - protected abstract int CalculatePageSize(int requestedPageSize); - protected abstract IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices); - - public void Clear() - { - _console.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 Process(RenderContext context, IEnumerable renderables) - { - lock (_lock) - { - if (!_live.HasRenderable) - { - Build(); - } - - yield return _live.PositionCursor(); - - foreach (var renderable in renderables) - { - yield return renderable; - } - - yield return _live; - } - } - - protected void Build() - { - var pageSize = CalculatePageSize(_requestedPageSize); - var middleOfList = pageSize / 2; - - var skip = 0; - var take = _choices.Count; - var pointer = _index; - - var scrollable = _choices.Count > pageSize; - if (scrollable) - { - skip = Math.Max(0, _index - middleOfList); - take = Math.Min(pageSize, _choices.Count - skip); - - if (_choices.Count - _index < middleOfList) - { - // Pointer should be below the end of the list - var diff = middleOfList - (_choices.Count - _index); - skip -= diff; - take += diff; - pointer = middleOfList + diff; - } - else - { - // Take skip into account - pointer -= skip; - } - } - - // Build the list - _live.SetRenderable(Build( - pointer, - scrollable, - _choices.Skip(skip).Take(take) - .Enumerate() - .Select(x => (skip + x.Index, x.Index, _converter(x.Item))))); - } - } -} diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs 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 : RenderableList - { - 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 to select, to accept)[/]"; - - private readonly IAnsiConsole _console; - private readonly string? _title; - private readonly Markup _moreChoices; - private readonly Markup _instructions; - private readonly Style _highlightStyle; - - public HashSet Selections { get; set; } - - public RenderableMultiSelectionList( - IAnsiConsole console, string? title, int pageSize, - List choices, HashSet selections, - Func? 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(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(); - - 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 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 : RenderableList - { - 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 choices, Func? 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(); - - 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 { /// - /// Represents a list prompt. + /// Represents a single list prompt. /// /// The prompt result type. - public sealed class SelectionPrompt : IPrompt + public sealed class SelectionPrompt : IPrompt, IListPromptStrategy + where T : notnull { + private readonly ListPromptTree _tree; + /// /// Gets or sets the title. /// public string? Title { get; set; } - /// - /// Gets the choices. - /// - public List Choices { get; } - - /// - /// Gets or sets the converter to get the display string for a choice. By default - /// the corresponding is used. - /// - public Func? Converter { get; set; } = TypeConverterHelper.ConvertToString; - /// /// Gets or sets the page size. /// Defaults to 10. @@ -38,68 +30,151 @@ namespace Spectre.Console /// public Style? HighlightStyle { get; set; } + /// + /// Gets or sets the style of a disabled choice. + /// + public Style? DisabledStyle { get; set; } + + /// + /// Gets or sets the converter to get the display string for a choice. By default + /// the corresponding is used. + /// + public Func? Converter { get; set; } + /// /// Gets or sets the text that will be displayed if there are more choices to show. /// public string? MoreChoicesText { get; set; } + /// + /// Gets or sets the selection mode. + /// Defaults to . + /// + public SelectionMode Mode { get; set; } = SelectionMode.Leaf; + /// /// Initializes a new instance of the class. /// public SelectionPrompt() { - Choices = new List(); + _tree = new ListPromptTree(); + } + + /// + /// Adds a choice. + /// + /// The item to add. + /// A so that multiple calls can be chained. + public ISelectionItem AddChoice(T item) + { + var node = new ListPromptItem(item); + _tree.Add(node); + return node; } /// - T IPrompt.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(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(() => + /// + ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState state) + { + if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar) { - var converter = Converter ?? TypeConverterHelper.ConvertToString; - var list = new RenderableSelectionList( - 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; + } + + /// + int IListPromptStrategy.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; + } + + /// + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + { + var list = new List(); + 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 { /// - /// Adds a choice. + /// Sets the selection mode. /// /// The prompt result type. /// The prompt. - /// The choice to add. + /// The selection mode. /// The same instance so that multiple calls can be chained. - public static SelectionPrompt AddChoice(this SelectionPrompt obj, T choice) + public static SelectionPrompt Mode(this SelectionPrompt 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 /// The choices to add. /// The same instance so that multiple calls can be chained. public static SelectionPrompt AddChoices(this SelectionPrompt 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 /// The choices to add. /// The same instance so that multiple calls can be chained. public static SelectionPrompt AddChoices(this SelectionPrompt obj, IEnumerable 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; + } + + /// + /// Adds multiple grouped choices. + /// + /// The prompt result type. + /// The prompt. + /// The group. + /// The choices to add. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt AddChoiceGroup(this SelectionPrompt obj, T group, IEnumerable 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 /// The title markup text. /// The same instance so that multiple calls can be chained. public static SelectionPrompt Title(this SelectionPrompt obj, string? title) + where T : notnull { if (obj is null) { @@ -88,6 +125,7 @@ namespace Spectre.Console /// The number of choices that are displayed to the user. /// The same instance so that multiple calls can be chained. public static SelectionPrompt PageSize(this SelectionPrompt obj, int pageSize) + where T : notnull { if (obj is null) { @@ -111,6 +149,7 @@ namespace Spectre.Console /// The highlight style of the selected choice. /// The same instance so that multiple calls can be chained. public static SelectionPrompt HighlightStyle(this SelectionPrompt obj, Style highlightStyle) + where T : notnull { if (obj is null) { @@ -129,6 +168,7 @@ namespace Spectre.Console /// The text to display. /// The same instance so that multiple calls can be chained. public static SelectionPrompt MoreChoicesText(this SelectionPrompt obj, string? text) + where T : notnull { if (obj is null) { @@ -147,6 +187,7 @@ namespace Spectre.Console /// The function to get a display string for a given choice. /// The same instance so that multiple calls can be chained. public static SelectionPrompt UseConverter(this SelectionPrompt obj, Func? displaySelector) + 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 +{ + /// + /// Represents how selections are made in a hierarchical prompt. + /// + public enum SelectionMode + { + /// + /// Will only return lead nodes in results. + /// + Leaf = 0, + + /// + /// Allows selection of parent nodes, but each node + /// is independent of its parent and children. + /// + Independent = 1, + } +}