From 397b742bec5ff94138bc7038403b01ee94291726 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 25 Feb 2024 11:57:27 +0000 Subject: [PATCH] Add selection orompt Search (#1289) * Add selection prompt search as you type * Fix small bug * Simplify * Simplify * Remove spacebar as a selection prompt submit key * Trigger CI * Update src/Spectre.Console/Prompts/SelectionPrompt.cs Co-authored-by: Martin Costello * Simplifty Mask method * Handle multi-selection prompt better * Update API naming * Address feedback * Add some tests * Remove whitespace * Improve search and highlighting * Add test case for previous issue * Add extra test case * Make prompt searchable --------- Co-authored-by: Martin Costello Co-authored-by: Patrik Svensson --- examples/Console/Prompt/Program.cs | 1 + .../Extensions/StringExtensions.cs | 106 ++++++++++++- .../Extensions/EnumerableExtensions.cs | 3 +- .../Prompts/List/IListPromptStrategy.cs | 5 +- .../Prompts/List/ListPrompt.cs | 17 ++- .../Prompts/List/ListPromptConstants.cs | 1 + .../Prompts/List/ListPromptState.cs | 144 ++++++++++++++++-- .../Prompts/MultiSelectionPrompt.cs | 5 +- .../Prompts/SelectionPrompt.cs | 56 ++++++- .../Prompts/SelectionPromptExtensions.cs | 55 +++++++ .../Extensions/ConsoleKeyExtensions.cs | 15 ++ .../Unit/HighlightTests.cs | 83 ++++++++++ .../Unit/Prompts/ListPromptStateTests.cs | 69 ++++++--- .../Unit/Prompts/SelectionPromptTests.cs | 65 ++++++++ 14 files changed, 567 insertions(+), 58 deletions(-) create mode 100644 test/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs create mode 100644 test/Spectre.Console.Tests/Unit/HighlightTests.cs diff --git a/examples/Console/Prompt/Program.cs b/examples/Console/Prompt/Program.cs index ab2cee434..216d029f3 100644 --- a/examples/Console/Prompt/Program.cs +++ b/examples/Console/Prompt/Program.cs @@ -110,6 +110,7 @@ public static string AskFruit() { fruit = AnsiConsole.Prompt( new SelectionPrompt() + .EnableSearch() .Title("Ok, but if you could only choose [green]one[/]?") .MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]") .AddChoices(favorites)); diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index d3b54ea45..0c324e4f1 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -187,6 +187,13 @@ internal static bool ContainsExact(this string text, string value) #endif } +#if NETSTANDARD2_0 + internal static bool Contains(this string target, string value, System.StringComparison comparisonType) + { + return target.IndexOf(value, comparisonType) != -1; + } +#endif + /// /// "Masks" every character in a string. /// @@ -195,18 +202,105 @@ internal static bool ContainsExact(this string text, string value) /// Masked string. public static string Mask(this string value, char? mask) { - var output = string.Empty; - if (mask is null) { - return output; + return string.Empty; + } + + return new string(mask.Value, value.Length); + } + + /// + /// Highlights the first text match in provided value. + /// + /// Input value. + /// Text to search for. + /// The style to apply to the matched text. + /// Markup of input with the first matched text highlighted. + internal static string Highlight(this string value, string searchText, Style? highlightStyle) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (searchText is null) + { + throw new ArgumentNullException(nameof(searchText)); + } + + if (highlightStyle is null) + { + throw new ArgumentNullException(nameof(highlightStyle)); + } + + if (searchText.Length == 0) + { + return value; } - foreach (var c in value) + var foundSearchPattern = false; + var builder = new StringBuilder(); + using var tokenizer = new MarkupTokenizer(value); + while (tokenizer.MoveNext()) { - output += mask; + var token = tokenizer.Current!; + + switch (token.Kind) + { + case MarkupTokenKind.Text: + { + var tokenValue = token.Value; + if (tokenValue.Length == 0) + { + break; + } + + if (foundSearchPattern) + { + builder.Append(tokenValue); + break; + } + + var index = tokenValue.IndexOf(searchText, StringComparison.OrdinalIgnoreCase); + if (index == -1) + { + builder.Append(tokenValue); + break; + } + + foundSearchPattern = true; + var before = tokenValue.Substring(0, index); + var match = tokenValue.Substring(index, searchText.Length); + var after = tokenValue.Substring(index + searchText.Length); + + builder + .Append(before) + .AppendWithStyle(highlightStyle, match) + .Append(after); + + break; + } + + case MarkupTokenKind.Open: + { + builder.Append("[" + token.Value + "]"); + break; + } + + case MarkupTokenKind.Close: + { + builder.Append("[/]"); + break; + } + + default: + { + throw new InvalidOperationException("Unknown markup token kind."); + } + } } - return output; + return builder.ToString(); } } \ No newline at end of file diff --git a/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs b/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs index 4616f8c98..fbb73bbbc 100644 --- a/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs +++ b/src/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs @@ -31,12 +31,11 @@ public static IEnumerable Repeat(this IEnumerable source, int count) } public static int IndexOf(this IEnumerable source, T item) - where T : class { var index = 0; foreach (var candidate in source) { - if (candidate == item) + if (Equals(candidate, item)) { return index; } diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index 4db8f7a79..a49358c61 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -31,6 +31,9 @@ internal interface IListPromptStrategy /// Whether or not the list is scrollable. /// The cursor index. /// The visible items. + /// A value indicating whether or not the prompt should skip unselectable items. + /// The search text. /// A representing the items. - public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items); + public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, + IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText); } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/List/ListPrompt.cs b/src/Spectre.Console/Prompts/List/ListPrompt.cs index 398a31535..7f6948c1a 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -14,9 +14,12 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) public async Task> Show( ListPromptTree tree, - CancellationToken cancellationToken, - int requestedPageSize = 15, - bool wrapAround = false) + SelectionMode selectionMode, + bool skipUnselectableItems, + bool searchEnabled, + int requestedPageSize, + bool wrapAround, + CancellationToken cancellationToken = default) { if (tree is null) { @@ -38,7 +41,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround); + var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) @@ -62,7 +65,7 @@ public async Task> Show( break; } - if (state.Update(key.Key) || result == ListPromptInputResult.Refresh) + if (state.Update(key) || result == ListPromptInputResult.Refresh) { hook.Refresh(); } @@ -110,6 +113,8 @@ private IRenderable BuildRenderable(ListPromptState state) _console, scrollable, cursorIndex, state.Items.Skip(skip).Take(take) - .Select((node, index) => (index, node))); + .Select((node, index) => (index, node)), + state.SkipUnselectableItems, + state.SearchText); } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/List/ListPromptConstants.cs b/src/Spectre.Console/Prompts/List/ListPromptConstants.cs index 86fecc94b..4c0e2b08c 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptConstants.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptConstants.cs @@ -8,4 +8,5 @@ internal sealed class ListPromptConstants public const string GroupSelectedCheckbox = "[[[grey]X[/]]]"; public const string InstructionsMarkup = "[grey](Press to select, to accept)[/]"; public const string MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]"; + public const string SearchPlaceholderMarkup = "[grey](Type to search)[/]"; } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index a6b698dee..177b97edb 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -7,37 +7,155 @@ internal sealed class ListPromptState public int ItemCount => Items.Count; public int PageSize { get; } public bool WrapAround { get; } + public SelectionMode Mode { get; } + public bool SkipUnselectableItems { get; private set; } + public bool SearchEnabled { get; } public IReadOnlyList> Items { get; } + private readonly IReadOnlyList? _leafIndexes; public ListPromptItem Current => Items[Index]; + public string SearchText { get; private set; } - public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround) + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled) { - Index = 0; Items = items; PageSize = pageSize; WrapAround = wrapAround; + Mode = mode; + SkipUnselectableItems = skipUnselectableItems; + SearchEnabled = searchEnabled; + SearchText = string.Empty; + + if (SkipUnselectableItems && mode == SelectionMode.Leaf) + { + _leafIndexes = + Items + .Select((item, index) => new { item, index }) + .Where(x => !x.item.IsGroup) + .Select(x => x.index) + .ToList() + .AsReadOnly(); + + Index = _leafIndexes.FirstOrDefault(); + } + else + { + Index = 0; + } } - public bool Update(ConsoleKey key) + public bool Update(ConsoleKeyInfo keyInfo) { - var index = key switch + var index = Index; + if (SkipUnselectableItems && Mode == SelectionMode.Leaf) { - ConsoleKey.UpArrow => Index - 1, - ConsoleKey.DownArrow => Index + 1, - ConsoleKey.Home => 0, - ConsoleKey.End => ItemCount - 1, - ConsoleKey.PageUp => Index - PageSize, - ConsoleKey.PageDown => Index + PageSize, - _ => Index, - }; + Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null"); + var currentLeafIndex = _leafIndexes.IndexOf(index); + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + if (currentLeafIndex > 0) + { + index = _leafIndexes[currentLeafIndex - 1]; + } + else if (WrapAround) + { + index = _leafIndexes.LastOrDefault(); + } + + break; + + case ConsoleKey.DownArrow: + if (currentLeafIndex < _leafIndexes.Count - 1) + { + index = _leafIndexes[currentLeafIndex + 1]; + } + else if (WrapAround) + { + index = _leafIndexes.FirstOrDefault(); + } + + break; + + case ConsoleKey.Home: + index = _leafIndexes.FirstOrDefault(); + break; + + case ConsoleKey.End: + index = _leafIndexes.LastOrDefault(); + break; + + case ConsoleKey.PageUp: + index = Math.Max(currentLeafIndex - PageSize, 0); + if (index < _leafIndexes.Count) + { + index = _leafIndexes[index]; + } + + break; + + case ConsoleKey.PageDown: + index = Math.Min(currentLeafIndex + PageSize, _leafIndexes.Count - 1); + if (index < _leafIndexes.Count) + { + index = _leafIndexes[index]; + } + + break; + } + } + else + { + index = keyInfo.Key switch + { + ConsoleKey.UpArrow => Index - 1, + ConsoleKey.DownArrow => Index + 1, + ConsoleKey.Home => 0, + ConsoleKey.End => ItemCount - 1, + ConsoleKey.PageUp => Index - PageSize, + ConsoleKey.PageDown => Index + PageSize, + _ => Index, + }; + } + + var search = SearchText; + + if (SearchEnabled) + { + // If is text input, append to search filter + if (!char.IsControl(keyInfo.KeyChar)) + { + search = SearchText + keyInfo.KeyChar; + var item = Items.FirstOrDefault(x => x.Data.ToString()?.Contains(search, StringComparison.OrdinalIgnoreCase) == true && (!x.IsGroup || Mode != SelectionMode.Leaf)); + if (item != null) + { + index = Items.IndexOf(item); + } + } + + if (keyInfo.Key == ConsoleKey.Backspace) + { + if (search.Length > 0) + { + search = search.Substring(0, search.Length - 1); + } + + var item = Items.FirstOrDefault(x => x.Data.ToString()?.Contains(search, StringComparison.OrdinalIgnoreCase) == true && (!x.IsGroup || Mode != SelectionMode.Leaf)); + if (item != null) + { + index = Items.IndexOf(item); + } + } + } index = WrapAround ? (ItemCount + (index % ItemCount)) % ItemCount : index.Clamp(0, ItemCount - 1); - if (index != Index) + + if (index != Index || SearchText != search) { Index = index; + SearchText = search; return true; } diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index e1ef43bba..0a023773c 100644 --- a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -94,7 +94,7 @@ public async Task> ShowAsync(IAnsiConsole console, CancellationToken can { // Create the list prompt var prompt = new ListPrompt(console, this); - var result = await prompt.Show(Tree, cancellationToken, PageSize, WrapAround).ConfigureAwait(false); + var result = await prompt.Show(Tree, Mode, false, false, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); if (Mode == SelectionMode.Leaf) { @@ -222,7 +222,8 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, + IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText) { var list = new List(); var highlightStyle = HighlightStyle ?? Color.Blue; diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index 4b9fdbeb7..e6ed46b87 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -36,6 +36,16 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public Style? DisabledStyle { get; set; } + /// + /// Gets or sets the style of highlighted search matches. + /// + public Style? SearchHighlightStyle { get; set; } + + /// + /// Gets or sets the text that will be displayed when no search text has been entered. + /// + public string? SearchPlaceholderText { get; set; } + /// /// Gets or sets the converter to get the display string for a choice. By default /// the corresponding is used. @@ -53,6 +63,11 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public SelectionMode Mode { get; set; } = SelectionMode.Leaf; + /// + /// Gets or sets a value indicating whether or not search is enabled. + /// + public bool SearchEnabled { get; set; } + /// /// Initializes a new instance of the class. /// @@ -84,7 +99,7 @@ public async Task ShowAsync(IAnsiConsole console, CancellationToken cancellat { // Create the list prompt var prompt = new ListPrompt(console, this); - var result = await prompt.Show(_tree, cancellationToken, PageSize, WrapAround).ConfigureAwait(false); + var result = await prompt.Show(_tree, Mode, true, SearchEnabled, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); // Return the selected item return result.Items[result.Index].Data; @@ -118,11 +133,20 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem extra += 2; } - // Scrolling? - if (totalItemCount > requestedPageSize) + var scrollable = totalItemCount > requestedPageSize; + if (SearchEnabled || scrollable) { - // The scrolling instructions takes up two rows - extra += 2; + extra += 1; + } + + if (SearchEnabled) + { + extra += 1; + } + + if (scrollable) + { + extra += 1; } if (requestedPageSize > console.Profile.Height - extra) @@ -134,11 +158,13 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, + IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText) { var list = new List(); var disabledStyle = DisabledStyle ?? Color.Grey; var highlightStyle = HighlightStyle ?? Color.Blue; + var searchHighlightStyle = SearchHighlightStyle ?? new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); if (Title != null) { @@ -169,15 +195,31 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, text = text.RemoveMarkup().EscapeMarkup(); } + if (searchText.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf)) + { + text = text.Highlight(searchText, searchHighlightStyle); + } + grid.AddRow(new Markup(indent + prompt + " " + text, style)); } list.Add(grid); + if (SearchEnabled || scrollable) + { + // Add padding + list.Add(Text.Empty); + } + + if (SearchEnabled) + { + list.Add(new Markup( + searchText.Length > 0 ? searchText.EscapeMarkup() : SearchPlaceholderText ?? ListPromptConstants.SearchPlaceholderMarkup)); + } + if (scrollable) { // (Move up and down to reveal more choices) - list.Add(Text.Empty); list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup)); } diff --git a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs index 1680b5e7a..a5d7be133 100644 --- a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -182,6 +182,61 @@ public static SelectionPrompt WrapAround(this SelectionPrompt obj, bool return obj; } + /// + /// Enables search for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt EnableSearch(this SelectionPrompt obj) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchEnabled = true; + return obj; + } + + /// + /// Disables search for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt DisableSearch(this SelectionPrompt obj) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchEnabled = false; + return obj; + } + + /// + /// Sets the text that will be displayed when no search text has been entered. + /// + /// The prompt result type. + /// The prompt. + /// The text to display. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt SearchPlaceholderText(this SelectionPrompt obj, string? text) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchPlaceholderText = text; + return obj; + } + /// /// Sets the highlight style of the selected choice. /// diff --git a/test/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs b/test/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs new file mode 100644 index 000000000..fee4ec22d --- /dev/null +++ b/test/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs @@ -0,0 +1,15 @@ +namespace Spectre.Console.Tests; + +public static class ConsoleKeyExtensions +{ + public static ConsoleKeyInfo ToConsoleKeyInfo(this ConsoleKey key) + { + var ch = (char)key; + if (char.IsControl(ch)) + { + ch = '\0'; + } + + return new ConsoleKeyInfo(ch, key, false, false, false); + } +} \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Unit/HighlightTests.cs b/test/Spectre.Console.Tests/Unit/HighlightTests.cs new file mode 100644 index 000000000..6575ddfd6 --- /dev/null +++ b/test/Spectre.Console.Tests/Unit/HighlightTests.cs @@ -0,0 +1,83 @@ +using Spectre.Console; + +namespace Namespace; + +public class HighlightTests +{ + private readonly Style _highlightStyle = new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); + + [Fact] + public void Should_Return_Same_Value_When_SearchText_Is_Empty() + { + // Given + var value = "Sample text"; + var searchText = string.Empty; + var highlightStyle = new Style(); + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe(value); + } + + [Fact] + public void Should_Highlight_Matched_Text() + { + // Given + var value = "Sample text with test word"; + var searchText = "test"; + var highlightStyle = _highlightStyle; + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe("Sample text with [bold on yellow]test[/] word"); + } + + [Fact] + public void Should_Not_Match_Text_Across_Tokens() + { + // Given + var value = "[red]Sample text[/] with test word"; + var searchText = "text with"; + var highlightStyle = _highlightStyle; + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe(value); + } + + [Fact] + public void Should_Highlight_Only_First_Matched_Text() + { + // Given + var value = "Sample text with test word"; + var searchText = "te"; + var highlightStyle = _highlightStyle; + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe("Sample [bold on yellow]te[/]xt with test word"); + } + + [Fact] + public void Should_Not_Match_Text_Outside_Of_Text_Tokens() + { + // Given + var value = "[red]Sample text with test word[/]"; + var searchText = "red"; + var highlightStyle = _highlightStyle; + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe(value); + } +} \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index 045c22fc0..cda4f3755 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs @@ -2,14 +2,14 @@ namespace Spectre.Console.Tests.Unit; public sealed class ListPromptStateTests { - private ListPromptState CreateListPromptState(int count, int pageSize, bool shouldWrap) - => new(Enumerable.Repeat(new ListPromptItem(string.Empty), count).ToList(), pageSize, shouldWrap); + private ListPromptState CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled) + => new(Enumerable.Range(0, count).Select(i => new ListPromptItem(i.ToString())).ToList(), pageSize, shouldWrap, SelectionMode.Independent, true, searchEnabled); [Fact] public void Should_Have_Start_Index_Zero() { // Given - var state = CreateListPromptState(100, 10, false); + var state = CreateListPromptState(100, 10, false, false); // When /* noop */ @@ -24,11 +24,11 @@ public void Should_Have_Start_Index_Zero() public void Should_Increase_Index(bool wrap) { // Given - var state = CreateListPromptState(100, 10, wrap); + var state = CreateListPromptState(100, 10, wrap, false); var index = state.Index; // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(index + 1); @@ -40,10 +40,10 @@ public void Should_Increase_Index(bool wrap) public void Should_Go_To_End(bool wrap) { // Given - var state = CreateListPromptState(100, 10, wrap); + var state = CreateListPromptState(100, 10, wrap, false); // When - state.Update(ConsoleKey.End); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -53,11 +53,11 @@ public void Should_Go_To_End(bool wrap) public void Should_Clamp_Index_If_No_Wrap() { // Given - var state = CreateListPromptState(100, 10, false); - state.Update(ConsoleKey.End); + var state = CreateListPromptState(100, 10, false, false); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -67,11 +67,11 @@ public void Should_Clamp_Index_If_No_Wrap() public void Should_Wrap_Index_If_Wrap() { // Given - var state = CreateListPromptState(100, 10, true); - state.Update(ConsoleKey.End); + var state = CreateListPromptState(100, 10, true, false); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(0); @@ -81,10 +81,10 @@ public void Should_Wrap_Index_If_Wrap() public void Should_Wrap_Index_If_Wrap_And_Down() { // Given - var state = CreateListPromptState(100, 10, true); + var state = CreateListPromptState(100, 10, true, false); // When - state.Update(ConsoleKey.UpArrow); + state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -94,10 +94,10 @@ public void Should_Wrap_Index_If_Wrap_And_Down() public void Should_Wrap_Index_If_Wrap_And_Page_Up() { // Given - var state = CreateListPromptState(10, 100, true); + var state = CreateListPromptState(10, 100, true, false); // When - state.Update(ConsoleKey.PageUp); + state.Update(ConsoleKey.PageUp.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(0); @@ -107,14 +107,41 @@ public void Should_Wrap_Index_If_Wrap_And_Page_Up() public void Should_Wrap_Index_If_Wrap_And_Offset_And_Page_Down() { // Given - var state = CreateListPromptState(10, 100, true); - state.Update(ConsoleKey.End); - state.Update(ConsoleKey.UpArrow); + var state = CreateListPromptState(10, 100, true, false); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); + state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo()); // When - state.Update(ConsoleKey.PageDown); + state.Update(ConsoleKey.PageDown.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(8); } + + [Fact] + public void Should_Jump_To_First_Matching_Item_When_Searching() + { + // Given + var state = CreateListPromptState(10, 100, true, true); + + // When + state.Update(ConsoleKey.D3.ToConsoleKeyInfo()); + + // Then + state.Index.ShouldBe(3); + } + + [Fact] + public void Should_Jump_Back_To_First_Item_When_Clearing_Search_Term() + { + // Given + var state = CreateListPromptState(10, 100, true, true); + + // When + state.Update(ConsoleKey.D3.ToConsoleKeyInfo()); + state.Update(ConsoleKey.Backspace.ToConsoleKeyInfo()); + + // Then + state.Index.ShouldBe(0); + } } diff --git a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs index 18851432c..5878c9df8 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs @@ -2,6 +2,8 @@ namespace Spectre.Console.Tests.Unit; public sealed class SelectionPromptTests { + private const string ESC = "\u001b"; + [Fact] public void Should_Not_Throw_When_Selecting_An_Item_With_Escaped_Markup() { @@ -20,4 +22,67 @@ public void Should_Not_Throw_When_Selecting_An_Item_With_Escaped_Markup() // Then console.Output.ShouldContain(@"[red]This text will never be red[/]"); } + + [Fact] + public void Should_Select_The_First_Leaf_Item() + { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .Mode(SelectionMode.Leaf) + .AddChoiceGroup("Group one", "A", "B") + .AddChoiceGroup("Group two", "C", "D"); + var selection = prompt.Show(console); + + // Then + selection.ShouldBe("A"); + } + + [Fact] + public void Should_Select_The_Last_Leaf_Item_When_Wrapping_Around() + { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.Input.PushKey(ConsoleKey.UpArrow); + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .Mode(SelectionMode.Leaf) + .WrapAround() + .AddChoiceGroup("Group one", "A", "B") + .AddChoiceGroup("Group two", "C", "D"); + var selection = prompt.Show(console); + + // Then + selection.ShouldBe("D"); + } + + [Fact] + public void Should_Highlight_Search_Term() + { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Interactive = true; + console.EmitAnsiSequences(); + console.Input.PushText("1"); + console.Input.PushKey(ConsoleKey.Enter); + + // When + var prompt = new SelectionPrompt() + .Title("Select one") + .EnableSearch() + .AddChoices("Item 1"); + prompt.Show(console); + + // Then + console.Output.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m"); + } }