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 letter keys (A-Z) to cycle indexes in Prompts #996

Open
wants to merge 4 commits 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
6 changes: 6 additions & 0 deletions src/Spectre.Console/Prompts/List/IListPromptStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ namespace Spectre.Console;
internal interface IListPromptStrategy<T>
where T : notnull
{
/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
Func<T, string>? Converter { get; set; }

/// <summary>
/// Handles any input received from the user.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public async Task<ListPromptState<T>> Show(
break;
}

if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
if (state.Update(key.Key, _strategy.Converter) || result == ListPromptInputResult.Refresh)
{
hook.Refresh();
}
Expand Down
52 changes: 45 additions & 7 deletions src/Spectre.Console/Prompts/List/ListPromptState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,50 @@ public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, boo
WrapAround = wrapAround;
}

public bool Update(ConsoleKey key)
public bool Update(ConsoleKey key, Func<T, string>? converter = null)
{
var index = key switch
var index = Index;

// if user presses a letter key
if (key >= ConsoleKey.A && key <= ConsoleKey.Z)
{
var keyStruck = new string((char)key, 1);

// find indexes of items that start with the letter
int[] matchingIndexes = Items.Select((item, idx) => (ItemToString(item, converter), idx))
.Where(k => k.Item1?.StartsWith(keyStruck, StringComparison.InvariantCultureIgnoreCase) ?? false)
.Select(k => k.idx)
.ToArray();

// if there are items beginning with this letter
if (matchingIndexes.Length > 0)
{
// is one of them currently selected?
var currentlySelected = Array.IndexOf(matchingIndexes, index);

if (currentlySelected == -1)
{
// we are not currently selecting any item beginning with the struck key
// so jump to first item in list that begins with the letter
index = matchingIndexes[0];
}
else
{
// cycle to next (circular)
index = matchingIndexes[(currentlySelected + 1) % matchingIndexes.Length];
}
}
}

index = key switch
{
ConsoleKey.UpArrow => Index - 1,
ConsoleKey.DownArrow => Index + 1,
ConsoleKey.UpArrow => index - 1,
ConsoleKey.DownArrow => index + 1,
ConsoleKey.Home => 0,
ConsoleKey.End => ItemCount - 1,
ConsoleKey.PageUp => Index - PageSize,
ConsoleKey.PageDown => Index + PageSize,
_ => Index,
ConsoleKey.PageUp => index - PageSize,
ConsoleKey.PageDown => index + PageSize,
_ => index,
};

index = WrapAround
Expand All @@ -43,4 +76,9 @@ public bool Update(ConsoleKey key)

return false;
}

private string ItemToString(ListPromptItem<T> item, Func<T, string>? converter)
{
return (converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Data) ?? item.Data.ToString() ?? "?";
}
}
5 changes: 1 addition & 4 deletions src/Spectre.Console/Prompts/MultiSelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrat
/// </summary>
public Style? HighlightStyle { get; set; }

/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
/// <inheritdoc/>
public Func<T, string>? Converter { get; set; }

/// <summary>
Expand Down
5 changes: 1 addition & 4 deletions src/Spectre.Console/Prompts/SelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,7 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
/// </summary>
public Style? DisabledStyle { get; set; }

/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
/// <inheritdoc/>
public Func<T, string>? Converter { get; set; }

/// <summary>
Expand Down
66 changes: 66 additions & 0 deletions test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,72 @@ public void Should_Increase_Index(bool wrap)
state.Index.ShouldBe(index + 1);
}

[Fact]
public void LetterJumpToSelection()
{
// Given
var state = new ListPromptState<string>(
new[]
{
new ListPromptItem<string>("apple"),
new ListPromptItem<string>("bannana"),
new ListPromptItem<string>("fish"),
new ListPromptItem<string>("flamingo"),
}
.ToList(), 3, true);

// First item should be selected
state.Index.ShouldBe(0);

// When user presses F
state.Update(ConsoleKey.F);

// Then should jump to fish
state.Index.ShouldBe(2);

// When user presses F again
state.Update(ConsoleKey.F);

// Then should jump to flamingo
state.Index.ShouldBe(3);

// When user presses F third time
state.Update(ConsoleKey.F);

// Then should cycle back to fish
state.Index.ShouldBe(2);
}

[Fact]
public void LetterJumpToSelection_WithConverter()
{
// Given
var state = new ListPromptState<int>(
new[]
{
new ListPromptItem<int>(5), // small
new ListPromptItem<int>(10), // small
new ListPromptItem<int>(200), // big
new ListPromptItem<int>(300), // big
}
.ToList(), 3, true);

// First item should be selected
state.Index.ShouldBe(0);

// String representation is "big" for values over 100 otherwise "small"
Func<int, string> converter = (i) => i > 100 ? "big" : "small";

state.Update(ConsoleKey.B, converter);
state.Index.ShouldBe(2);

state.Update(ConsoleKey.B, converter);
state.Index.ShouldBe(3);

state.Update(ConsoleKey.B, converter);
state.Index.ShouldBe(2);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
Expand Down