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
9 changes: 8 additions & 1 deletion src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy<T> strategy)
}

var nodes = tree.Traverse().ToList();
var state = new ListPromptState<T>(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled);
var state = new ListPromptState<T>(
nodes,
_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
13 changes: 10 additions & 3 deletions src/Spectre.Console/Prompts/List/ListPromptState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal sealed class ListPromptState<T>
public ListPromptItem<T> Current => Items[Index];
public string SearchText { get; private set; }

public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled)
public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled, int initialIndex)
{
Items = items;
PageSize = pageSize;
Expand All @@ -36,11 +36,18 @@ public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, boo
.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 @@ int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItem
// 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 @@ -225,4 +235,15 @@ int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItem

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;
}
}
14 changes: 8 additions & 6 deletions test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ namespace Spectre.Console.Tests.Unit;

public sealed class ListPromptStateTests
{
private ListPromptState<string> CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled)
=> new(Enumerable.Range(0, count).Select(i => new ListPromptItem<string>(i.ToString())).ToList(), pageSize, shouldWrap, SelectionMode.Independent, true, 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(), 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
197 changes: 197 additions & 0 deletions test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,201 @@ public void Should_Highlight_Search_Term()
// Then
console.Output.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m");
}

[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 ",
]);
}
}