Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow SelectionPrompt to have an initial selection #1541

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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