From e7cb66367503324ce1b4daa9478129cef088b759 Mon Sep 17 00:00:00 2001 From: reduckted Date: Thu, 9 Nov 2023 22:07:53 +1000 Subject: [PATCH] Allow SelectionPrompt to have an initial selection --- .../Prompts/List/IListPromptStrategy.cs | 7 + .../Prompts/List/ListPrompt.cs | 10 +- .../Prompts/List/ListPromptState.cs | 14 +- .../Prompts/List/ListPromptTree.cs | 16 ++ .../Prompts/MultiSelectionPrompt.cs | 6 + .../Prompts/SelectionPrompt.cs | 25 ++- .../Prompts/SelectionPromptExtensions.cs | 19 ++ .../Unit/Prompts/ListPromptStateTests.cs | 14 +- .../Unit/Prompts/SelectionPromptTests.cs | 191 ++++++++++++++++++ 9 files changed, 290 insertions(+), 12 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index a49358c61..ce3e6a49e 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -15,6 +15,13 @@ internal interface IListPromptStrategy /// A result representing an action. ListPromptInputResult HandleInput(ConsoleKeyInfo key, ListPromptState state); + /// + /// Calculates the state's initial index. + /// + /// The nodes that will be shown in the list. + /// The initial index that should be used. + public int CalculateInitialIndex(IReadOnlyList> nodes); + /// /// Calculates the page size. /// diff --git a/src/Spectre.Console/Prompts/List/ListPrompt.cs b/src/Spectre.Console/Prompts/List/ListPrompt.cs index ca0fa3fee..caef51d5a 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -47,7 +47,15 @@ public async Task> Show( throw new InvalidOperationException("Cannot show an empty selection prompt. Please call the AddChoice() method to configure the prompt."); } - var state = new ListPromptState(nodes, converter, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled); + var state = new ListPromptState( + nodes, + converter, + _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), + wrapAround, + selectionMode, + skipUnselectableItems, + searchEnabled, + _strategy.CalculateInitialIndex(nodes)); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index fdbe3c803..2f2d502c2 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -24,7 +24,8 @@ public ListPromptState( int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, - bool searchEnabled) + bool searchEnabled, + int initialIndex) { _converter = converter ?? throw new ArgumentNullException(nameof(converter)); Items = items; @@ -45,11 +46,18 @@ public ListPromptState( .ToList() .AsReadOnly(); - Index = _leafIndexes.FirstOrDefault(); + if (_leafIndexes.Contains(initialIndex)) + { + Index = initialIndex; + } + else + { + Index = _leafIndexes.FirstOrDefault(); + } } else { - Index = 0; + Index = initialIndex; } } diff --git a/src/Spectre.Console/Prompts/List/ListPromptTree.cs b/src/Spectre.Console/Prompts/List/ListPromptTree.cs index 23e466a9e..c3e4a13a1 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptTree.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptTree.cs @@ -29,6 +29,22 @@ public ListPromptTree(IEqualityComparer comparer) return null; } + public int? IndexOf(T item) + { + var index = 0; + foreach (var node in Traverse()) + { + if (_comparer.Equals(item, node.Data)) + { + return index; + } + + index++; + } + + return null; + } + public void Add(ListPromptItem node) { _roots.Add(node); diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index ffc3f4e58..557bd5a21 100644 --- a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -278,4 +278,10 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, // Combine all items return new Rows(list); } + + /// + int IListPromptStrategy.CalculateInitialIndex(IReadOnlyList> nodes) + { + return 0; + } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index c9f55690e..e9abe7234 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -68,12 +68,22 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public bool SearchEnabled { get; set; } + /// + /// Gets or sets the choice to show as selected when the prompt is first displayed. + /// By default the first choice is selected. + /// + public T? DefaultValue { get; set; } + /// /// Initializes a new instance of the class. /// - public SelectionPrompt() + /// + /// The implementation to use when comparing items, + /// or null to use the default for the type of the item. + /// + public SelectionPrompt(IEqualityComparer? comparer = null) { - _tree = new ListPromptTree(EqualityComparer.Default); + _tree = new ListPromptTree(comparer ?? EqualityComparer.Default); } /// @@ -226,4 +236,15 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, return new Rows(list); } + + /// + int IListPromptStrategy.CalculateInitialIndex(IReadOnlyList> nodes) + { + if (DefaultValue is not null) + { + return _tree.IndexOf(DefaultValue) ?? 0; + } + + return 0; + } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs index a5d7be133..98edd61b8 100644 --- a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -293,4 +293,23 @@ public static SelectionPrompt UseConverter(this SelectionPrompt obj, Fu obj.Converter = displaySelector; return obj; } + + /// + /// Sets the choice that will be selected when the prompt is first displayed. + /// + /// The prompt result type. + /// The prompt. + /// The choice to show as selected when the prompt is first displayed. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt DefaultValue(this SelectionPrompt obj, T? defaultValue) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.DefaultValue = defaultValue; + return obj; + } } \ No newline at end of file diff --git a/src/Tests/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/src/Tests/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index 2a45b66bc..28d404182 100644 --- a/src/Tests/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs +++ b/src/Tests/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs @@ -2,23 +2,25 @@ namespace Spectre.Console.Tests.Unit; public sealed class ListPromptStateTests { - private ListPromptState CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled) + private ListPromptState CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled, int initialIndex = 0) => new( Enumerable.Range(0, count).Select(i => new ListPromptItem(i.ToString())).ToList(), text => text, - pageSize, shouldWrap, SelectionMode.Independent, true, searchEnabled); + pageSize, shouldWrap, SelectionMode.Independent, true, searchEnabled, initialIndex); - [Fact] - public void Should_Have_Start_Index_Zero() + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Should_Have_Specified_Start_Index(int index) { // Given - var state = CreateListPromptState(100, 10, false, false); + var state = CreateListPromptState(100, 10, false, false, initialIndex: index); // When /* noop */ // Then - state.Index.ShouldBe(0); + state.Index.ShouldBe(index); } [Theory] diff --git a/src/Tests/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs b/src/Tests/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs index 2e066c5f6..7800bf21d 100644 --- a/src/Tests/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs +++ b/src/Tests/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs @@ -130,6 +130,197 @@ [Fact] public void Should_Throw_Meaningful_Exception_For_Empty_Prompt() var exception = action.ShouldThrow(); exception.Message.ShouldBe("Cannot show an empty selection prompt. Please call the AddChoice() method to configure the prompt."); } + + + [Fact] + public void Should_Initially_Select_The_First_Item_When_No_Default_Is_Specified() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoices("First", "Second", "Third"); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one", + " ", + "> First ", + " Second ", + " Third ", + ]); + } + + [Fact] + public void Should_Initially_Select_The_Default_Item_When_It_Exists_In_The_Choices() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoices("First", "Second", "Third") + .DefaultValue("Second"); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one", + " ", + " First ", + "> Second ", + " Third ", + ]); + } + + [Fact] + public void Should_Initially_Select_The_First_Item_When_Default_Does_Not_Exist_In_The_Choices() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoices("First", "Second", "Third") + .DefaultValue("Fourth"); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one", + " ", + "> First ", + " Second ", + " Third ", + ]); + } + + [Fact] + public void Should_Initially_Select_The_Default_Item_When_Scrolling_Is_Required_And_Item_Is_Not_Last() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoices("First", "Second", "Third", "Fourth", "Fifth", "Sixth") + .DefaultValue("Third") + .PageSize(3); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one ", + " ", + " Second ", + "> Third ", + " Fourth ", + " ", + "(Move up and down to reveal more choices)", + ]); + } + + [Fact] + public void Should_Initially_Select_The_Default_Item_When_Scrolling_Is_Required_And_Item_Is_Last() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoices("First", "Second", "Third", "Fourth", "Fifth", "Sixth") + .DefaultValue("Sixth") + .PageSize(3); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one ", + " ", + " Fourth ", + " Fifth ", + "> Sixth ", + " ", + "(Move up and down to reveal more choices)", + ]); + } + + [Fact] + public void Should_Initially_Select_The_Default_Value_When_Skipping_Unselectable_Items_And_Default_Value_Is_Leaf() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoiceGroup("Group one", "First", "Second") + .AddChoiceGroup("Group two", "Third", "Fourth") + .Mode(SelectionMode.Leaf) + .DefaultValue("Third"); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one ", + " ", + " Group one ", + " First ", + " Second ", + " Group two ", + " > Third ", + " Fourth ", + ]); + } + + [Fact] + public void Should_Initially_Select_The_First_Leaf_When_Skipping_Unselectable_Items_And_Default_Value_Is_Not_Leaf() { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .AddChoiceGroup("Group one", "First", "Second") + .AddChoiceGroup("Group two", "Third", "Fourth") + .Mode(SelectionMode.Leaf) + .DefaultValue("Group two"); + + prompt.Show(console); + + // Then + console.Lines.ShouldBe([ + "Select one ", + " ", + " Group one ", + " > First ", + " Second ", + " Group two ", + " Third ", + " Fourth ", + ]); + } } file sealed class CustomSelectionItem