From 99392f8b1a9f75b7149ba7cca627b395aa6eb446 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 3 Sep 2023 19:15:06 +0100 Subject: [PATCH] Add selection prompt search as you type --- .../Extensions/StringExtensions.cs | 53 ++++---- .../Extensions/EnumerableExtensions.cs | 3 +- .../Prompts/List/IListPromptStrategy.cs | 10 +- .../Prompts/List/ListPrompt.cs | 7 +- .../Prompts/List/ListPromptConstants.cs | 1 + .../Prompts/List/ListPromptState.cs | 128 ++++++++++++++++-- .../Prompts/MultiSelectionPrompt.cs | 9 +- .../Prompts/SelectionPrompt.cs | 44 +++++- .../Prompts/SelectionPromptExtensions.cs | 19 +++ .../Extensions/ConsoleKeyExtensions.cs | 15 ++ .../Unit/Prompts/ListPromptStateTests.cs | 24 ++-- 11 files changed, 257 insertions(+), 56 deletions(-) create mode 100644 test/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index e6485e962..313fb6d35 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -185,28 +185,35 @@ internal static bool ContainsExact(this string text, string value) #else return text.Contains(value, StringComparison.Ordinal); #endif - } - - /// - /// "Masks" every character in a string. - /// - /// String value to mask. - /// Character to use for masking. - /// Masked string. - public static string Mask(this string value, char? mask) - { - var output = string.Empty; - - if (mask is null) - { - return output; - } - - foreach (var c in value) - { - output += mask; - } - - return output; + } + +#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. + /// + /// String value to mask. + /// Character to use for masking. + /// Masked string. + public static string Mask(this string value, char? mask) + { + var output = string.Empty; + + if (mask is null) + { + return output; + } + + foreach (var c in value) + { + output += mask; + } + + return output; } } \ 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..d4b0b8a04 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -24,6 +24,12 @@ internal interface IListPromptStrategy /// The page size that should be used. public int CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize); + /// + /// Gets the selection mode. + /// + /// The selection mode. + public SelectionMode GetSelectionMode(); + /// /// Builds a from the current state. /// @@ -31,6 +37,8 @@ internal interface IListPromptStrategy /// Whether or not the list is scrollable. /// The cursor index. /// The visible items. + /// The search filter. /// 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, string searchFilter); } \ 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 f30f4d348..e116f0d75 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -38,7 +38,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, _strategy.GetSelectionMode()); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) @@ -61,7 +61,7 @@ public async Task> Show( break; } - if (state.Update(key.Key) || result == ListPromptInputResult.Refresh) + if (state.Update(key) || result == ListPromptInputResult.Refresh) { hook.Refresh(); } @@ -109,6 +109,7 @@ 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.SearchFilter); } } \ 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..c356956dd 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -7,37 +7,139 @@ internal sealed class ListPromptState public int ItemCount => Items.Count; public int PageSize { get; } public bool WrapAround { get; } + public SelectionMode Mode { get; } public IReadOnlyList> Items { get; } + private readonly IReadOnlyList _leafIndexes; public ListPromptItem Current => Items[Index]; + public string SearchFilter { get; set; } - public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround) + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode) { - Index = 0; Items = items; PageSize = pageSize; WrapAround = wrapAround; + Mode = mode; + SearchFilter = string.Empty; + + _leafIndexes = Items + .Select((item, index) => new { item, index }) + .Where(x => !x.item.IsGroup) + .Select(x => x.index) + .ToList() + .AsReadOnly(); + + Index = _leafIndexes.FirstOrDefault(); } - public bool Update(ConsoleKey key) + public bool Update(ConsoleKeyInfo keyInfo) { - var index = key switch + var index = Index; + if (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, - }; + 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 = SearchFilter; + + // If is text input, append to search filter + if (!char.IsControl(keyInfo.KeyChar)) + { + search = SearchFilter + 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 || SearchFilter != search) { Index = index; + SearchFilter = search; return true; } diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index e1ef43bba..6a1b3faf0 100644 --- a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -222,7 +222,14 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + public SelectionMode GetSelectionMode() + { + return this.Mode; + } + + /// + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, + IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) { 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..e42d86d55 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -36,6 +36,11 @@ 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 converter to get the display string for a choice. By default /// the corresponding is used. @@ -53,6 +58,11 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public SelectionMode Mode { get; set; } = SelectionMode.Leaf; + /// + /// Gets or sets a value indicating whether or not the search filter is enabled. + /// + public bool SearchFilterEnabled { get; set; } = false; + /// /// Initializes a new instance of the class. /// @@ -134,11 +144,19 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + public SelectionMode GetSelectionMode() + { + return this.Mode; + } + + /// + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, + IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) { 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,6 +187,23 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, text = text.RemoveMarkup().EscapeMarkup(); } + if (stateSearchFilter.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf)) + { + var index = text.IndexOf(stateSearchFilter, StringComparison.OrdinalIgnoreCase); + if (index >= 0) + { + var before = text.Substring(0, index); + var match = text.Substring(index, stateSearchFilter.Length); + var after = text.Substring(index + stateSearchFilter.Length); + + text = new StringBuilder() + .Append(before) + .AppendWithStyle(searchHighlightStyle, match) + .Append(after) + .ToString(); + } + } + grid.AddRow(new Markup(indent + prompt + " " + text, style)); } @@ -181,6 +216,13 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup)); } + if (SearchFilterEnabled) + { + list.Add(Text.Empty); + list.Add(new Markup( + $"search: {(stateSearchFilter.Length > 0 ? stateSearchFilter.EscapeMarkup() : ListPromptConstants.SearchPlaceholderMarkup)}")); + } + return new Rows(list); } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs index 1680b5e7a..40750c497 100644 --- a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -182,6 +182,25 @@ public static SelectionPrompt WrapAround(this SelectionPrompt obj, bool return obj; } + /// + /// Sets whether the search filter should be enabled. + /// + /// The prompt result type. + /// The prompt. + /// Whether the search filter should be enabled. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt SearchFilter(this SelectionPrompt obj, bool enabled = true) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchFilterEnabled = enabled; + 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/Prompts/ListPromptStateTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index 045c22fc0..bc4facb4b 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs @@ -3,7 +3,7 @@ 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); + => new(Enumerable.Repeat(new ListPromptItem(string.Empty), count).ToList(), pageSize, shouldWrap, SelectionMode.Independent); [Fact] public void Should_Have_Start_Index_Zero() @@ -28,7 +28,7 @@ public void Should_Increase_Index(bool wrap) var index = state.Index; // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(index + 1); @@ -43,7 +43,7 @@ public void Should_Go_To_End(bool wrap) var state = CreateListPromptState(100, 10, wrap); // When - state.Update(ConsoleKey.End); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -54,10 +54,10 @@ public void Should_Clamp_Index_If_No_Wrap() { // Given var state = CreateListPromptState(100, 10, false); - state.Update(ConsoleKey.End); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -68,10 +68,10 @@ public void Should_Wrap_Index_If_Wrap() { // Given var state = CreateListPromptState(100, 10, true); - state.Update(ConsoleKey.End); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When - state.Update(ConsoleKey.DownArrow); + state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(0); @@ -84,7 +84,7 @@ public void Should_Wrap_Index_If_Wrap_And_Down() var state = CreateListPromptState(100, 10, true); // When - state.Update(ConsoleKey.UpArrow); + state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(99); @@ -97,7 +97,7 @@ public void Should_Wrap_Index_If_Wrap_And_Page_Up() var state = CreateListPromptState(10, 100, true); // When - state.Update(ConsoleKey.PageUp); + state.Update(ConsoleKey.PageUp.ToConsoleKeyInfo()); // Then state.Index.ShouldBe(0); @@ -108,11 +108,11 @@ 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); + 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);