Skip to content

Commit

Permalink
Allow SelectionPrompt to have an initial selection
Browse files Browse the repository at this point in the history
  • Loading branch information
reduckted committed Oct 2, 2024
1 parent 75547b2 commit e7cb663
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 12 deletions.
7 changes: 7 additions & 0 deletions src/Spectre.Console/Prompts/List/IListPromptStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ internal interface IListPromptStrategy<T>
/// <returns>A result representing an action.</returns>
ListPromptInputResult HandleInput(ConsoleKeyInfo key, ListPromptState<T> state);

/// <summary>
/// Calculates the state's initial index.
/// </summary>
/// <param name="nodes">The nodes that will be shown in the list.</param>
/// <returns>The initial index that should be used.</returns>
public int CalculateInitialIndex(IReadOnlyList<ListPromptItem<T>> nodes);

/// <summary>
/// Calculates the page size.
/// </summary>
Expand Down
10 changes: 9 additions & 1 deletion src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ public async Task<ListPromptState<T>> Show(
throw new InvalidOperationException("Cannot show an empty selection prompt. Please call the AddChoice() method to configure the prompt.");
}

var state = new ListPromptState<T>(nodes, converter, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled);
var state = new ListPromptState<T>(
nodes,
converter,
_strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize),
wrapAround,
selectionMode,
skipUnselectableItems,
searchEnabled,
_strategy.CalculateInitialIndex(nodes));
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));

using (new RenderHookScope(_console, hook))
Expand Down
14 changes: 11 additions & 3 deletions src/Spectre.Console/Prompts/List/ListPromptState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/Spectre.Console/Prompts/List/ListPromptTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ public ListPromptTree(IEqualityComparer<T> 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<T> node)
{
_roots.Add(node);
Expand Down
6 changes: 6 additions & 0 deletions src/Spectre.Console/Prompts/MultiSelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,10 @@ IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable,
// Combine all items
return new Rows(list);
}

/// <inheritdoc/>
int IListPromptStrategy<T>.CalculateInitialIndex(IReadOnlyList<ListPromptItem<T>> nodes)
{
return 0;
}
}
25 changes: 23 additions & 2 deletions src/Spectre.Console/Prompts/SelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,22 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
/// </summary>
public bool SearchEnabled { get; set; }

/// <summary>
/// Gets or sets the choice to show as selected when the prompt is first displayed.
/// By default the first choice is selected.
/// </summary>
public T? DefaultValue { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
/// </summary>
public SelectionPrompt()
/// <param name="comparer">
/// The <see cref="IEqualityComparer{T}"/> implementation to use when comparing items,
/// or <c>null</c> to use the default <see cref="IEqualityComparer{T}"/> for the type of the item.
/// </param>
public SelectionPrompt(IEqualityComparer<T>? comparer = null)
{
_tree = new ListPromptTree<T>(EqualityComparer<T>.Default);
_tree = new ListPromptTree<T>(comparer ?? EqualityComparer<T>.Default);
}

/// <summary>
Expand Down Expand Up @@ -226,4 +236,15 @@ IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable,

return new Rows(list);
}

/// <inheritdoc/>
int IListPromptStrategy<T>.CalculateInitialIndex(IReadOnlyList<ListPromptItem<T>> nodes)
{
if (DefaultValue is not null)
{
return _tree.IndexOf(DefaultValue) ?? 0;
}

return 0;
}
}
19 changes: 19 additions & 0 deletions src/Spectre.Console/Prompts/SelectionPromptExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,23 @@ public static SelectionPrompt<T> UseConverter<T>(this SelectionPrompt<T> obj, Fu
obj.Converter = displaySelector;
return obj;
}

/// <summary>
/// Sets the choice that will be selected when the prompt is first displayed.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The prompt.</param>
/// <param name="defaultValue">The choice to show as selected when the prompt is first displayed.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static SelectionPrompt<T> DefaultValue<T>(this SelectionPrompt<T> obj, T? defaultValue)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

obj.DefaultValue = defaultValue;
return obj;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@ namespace Spectre.Console.Tests.Unit;

public sealed class ListPromptStateTests
{
private ListPromptState<string> CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled)
private ListPromptState<string> CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled, int initialIndex = 0)
=> new(
Enumerable.Range(0, count).Select(i => new ListPromptItem<string>(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]
Expand Down
191 changes: 191 additions & 0 deletions src/Tests/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,197 @@ [Fact] public void Should_Throw_Meaningful_Exception_For_Empty_Prompt()
var exception = action.ShouldThrow<InvalidOperationException>();
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<string>()
.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<string>()
.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<string>()
.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<string>()
.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<string>()
.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<string>()
.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<string>()
.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
Expand Down

0 comments on commit e7cb663

Please sign in to comment.