From c25c99462a13703393154d192b1977436d554a45 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 3 Sep 2023 19:15:06 +0100 Subject: [PATCH 01/17] Add selection prompt search as you type --- .../Extensions/StringExtensions.cs | 7 + .../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, 234 insertions(+), 33 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 d3b54ea45..313fb6d35 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. /// 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 398a31535..f1e0c5620 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)) @@ -62,7 +62,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 +110,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); From cca90666b1daece6465d0eee7ab76a0e444d6a5d Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 14:41:35 +0100 Subject: [PATCH 02/17] Fix small bug --- .../Prompts/List/ListPromptState.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index c356956dd..03e4b519c 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -22,12 +22,15 @@ public ListPromptState(IReadOnlyList> items, int pageSize, boo Mode = mode; SearchFilter = string.Empty; - _leafIndexes = Items - .Select((item, index) => new { item, index }) - .Where(x => !x.item.IsGroup) - .Select(x => x.index) - .ToList() - .AsReadOnly(); + _leafIndexes = + mode == SelectionMode.Leaf + ? Items + .Select((item, index) => new { item, index }) + .Where(x => !x.item.IsGroup) + .Select(x => x.index) + .ToList() + .AsReadOnly() + : new List().AsReadOnly(); Index = _leafIndexes.FirstOrDefault(); } From 369c3016f415b8969aded26df8bbf5f3617bb3ea Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 14:43:13 +0100 Subject: [PATCH 03/17] Simplify --- .../Prompts/List/ListPromptState.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index 03e4b519c..28d51c5b1 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -22,17 +22,22 @@ public ListPromptState(IReadOnlyList> items, int pageSize, boo Mode = mode; SearchFilter = string.Empty; - _leafIndexes = - mode == SelectionMode.Leaf - ? Items + if (mode == SelectionMode.Leaf) + { + _leafIndexes = + Items .Select((item, index) => new { item, index }) .Where(x => !x.item.IsGroup) .Select(x => x.index) .ToList() - .AsReadOnly() - : new List().AsReadOnly(); + .AsReadOnly(); - Index = _leafIndexes.FirstOrDefault(); + Index = _leafIndexes.FirstOrDefault(); + } + else + { + Index = 0; + } } public bool Update(ConsoleKeyInfo keyInfo) From 4bc08bbfd33c4a9d8939e1ddbf618796a1a707c7 Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 15:12:34 +0100 Subject: [PATCH 04/17] Simplify --- .../Prompts/List/IListPromptStrategy.cs | 6 --- .../Prompts/List/ListPrompt.cs | 10 +++-- .../Prompts/List/ListPromptState.cs | 44 +++++++++++-------- .../Prompts/MultiSelectionPrompt.cs | 8 +--- .../Prompts/SelectionPrompt.cs | 14 +++--- .../Unit/Prompts/ListPromptStateTests.cs | 2 +- 6 files changed, 40 insertions(+), 44 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index d4b0b8a04..76fa3f447 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -24,12 +24,6 @@ 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. /// diff --git a/src/Spectre.Console/Prompts/List/ListPrompt.cs b/src/Spectre.Console/Prompts/List/ListPrompt.cs index f1e0c5620..a992bae0b 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -14,9 +14,11 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) public async Task> Show( ListPromptTree tree, - CancellationToken cancellationToken, - int requestedPageSize = 15, - bool wrapAround = false) + SelectionMode selectionMode, + bool searchFilterEnabled, + int requestedPageSize, + bool wrapAround, + CancellationToken cancellationToken = default) { if (tree is null) { @@ -38,7 +40,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, _strategy.GetSelectionMode()); + var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, searchFilterEnabled); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index 28d51c5b1..ae82be138 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -8,18 +8,20 @@ internal sealed class ListPromptState public int PageSize { get; } public bool WrapAround { get; } public SelectionMode Mode { get; } + public bool SearchFilterEnabled { get; } public IReadOnlyList> Items { get; } - private readonly IReadOnlyList _leafIndexes; + private readonly IReadOnlyList? _leafIndexes; public ListPromptItem Current => Items[Index]; public string SearchFilter { get; set; } - public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode) + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool searchFilterEnabled) { Items = items; PageSize = pageSize; WrapAround = wrapAround; Mode = mode; + SearchFilterEnabled = searchFilterEnabled; SearchFilter = string.Empty; if (mode == SelectionMode.Leaf) @@ -45,6 +47,7 @@ public bool Update(ConsoleKeyInfo keyInfo) var index = Index; if (Mode == SelectionMode.Leaf) { + Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null"); var currentLeafIndex = _leafIndexes.IndexOf(index); switch (keyInfo.Key) { @@ -115,28 +118,31 @@ public bool Update(ConsoleKeyInfo keyInfo) var search = SearchFilter; - // If is text input, append to search filter - if (!char.IsControl(keyInfo.KeyChar)) + if (SearchFilterEnabled) { - 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) + // If is text input, append to search filter + if (!char.IsControl(keyInfo.KeyChar)) { - index = Items.IndexOf(item); - } - } - - if (keyInfo.Key == ConsoleKey.Backspace) - { - if (search.Length > 0) - { - search = search.Substring(0, search.Length - 1); + 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); + } } - var item = Items.FirstOrDefault(x => x.Data.ToString()?.Contains(search, StringComparison.OrdinalIgnoreCase) == true && (!x.IsGroup || Mode != SelectionMode.Leaf)); - if (item != null) + if (keyInfo.Key == ConsoleKey.Backspace) { - index = Items.IndexOf(item); + 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); + } } } diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index 6a1b3faf0..773fddc4b 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, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); if (Mode == SelectionMode.Leaf) { @@ -221,12 +221,6 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem return pageSize; } - /// - public SelectionMode GetSelectionMode() - { - return this.Mode; - } - /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index e42d86d55..a4fb6ef1c 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -94,7 +94,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, SearchFilterEnabled, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); // Return the selected item return result.Items[result.Index].Data; @@ -135,6 +135,12 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem extra += 2; } + if (SearchFilterEnabled) + { + // The search instructions takes up two rows + extra += 2; + } + if (requestedPageSize > console.Profile.Height - extra) { return console.Profile.Height - extra; @@ -143,12 +149,6 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem return requestedPageSize; } - /// - public SelectionMode GetSelectionMode() - { - return this.Mode; - } - /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) diff --git a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index bc4facb4b..db3368886 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, SelectionMode.Independent); + => new(Enumerable.Repeat(new ListPromptItem(string.Empty), count).ToList(), pageSize, shouldWrap, SelectionMode.Independent, false); [Fact] public void Should_Have_Start_Index_Zero() From a50ceabb3ee66df7978ea6fda9c017ce1ebe3055 Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 15:17:19 +0100 Subject: [PATCH 05/17] Remove spacebar as a selection prompt submit key --- src/Spectre.Console/Prompts/SelectionPrompt.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index a4fb6ef1c..5a35971c7 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -103,7 +103,7 @@ public async Task ShowAsync(IAnsiConsole console, CancellationToken cancellat /// ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState state) { - if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar || key.Key == ConsoleKey.Packet) + if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Packet) { // Selecting a non leaf in "leaf mode" is not allowed if (state.Current.IsGroup && Mode == SelectionMode.Leaf) From b88d58eb7ab9d8aa2ee8843945bd45d9b7e7654a Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 15:28:50 +0100 Subject: [PATCH 06/17] Trigger CI From 8700f4a0e95d09a83595fb01603819368f0ccc35 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 4 Sep 2023 15:56:37 +0100 Subject: [PATCH 07/17] Update src/Spectre.Console/Prompts/SelectionPrompt.cs Co-authored-by: Martin Costello --- src/Spectre.Console/Prompts/SelectionPrompt.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index 5a35971c7..abf98da4e 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -61,7 +61,7 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// /// Gets or sets a value indicating whether or not the search filter is enabled. /// - public bool SearchFilterEnabled { get; set; } = false; + public bool SearchFilterEnabled { get; set; } /// /// Initializes a new instance of the class. From 4f2153733e66304c8a4c8c10ac12364405623380 Mon Sep 17 00:00:00 2001 From: Stuart lang Date: Mon, 4 Sep 2023 16:02:01 +0100 Subject: [PATCH 08/17] Simplifty Mask method --- src/Spectre.Console/Extensions/StringExtensions.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index 313fb6d35..06d0554ef 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -202,18 +202,11 @@ internal static bool Contains(this string target, string value, System.StringCom /// 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 string.Empty; } - return output; + return new string(mask.Value, value.Length); } } \ No newline at end of file From 906ca2a36e5cfb945b36b2def34f8b2c7871d73e Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 4 Sep 2023 19:52:25 +0100 Subject: [PATCH 09/17] Handle multi-selection prompt better --- .../Prompts/List/IListPromptStrategy.cs | 5 +++ .../Prompts/List/ListPrompt.cs | 2 +- .../Prompts/List/ListPromptState.cs | 10 +++--- .../Prompts/MultiSelectionPrompt.cs | 3 ++ .../Prompts/SelectionPrompt.cs | 32 ++++++++++++------- .../Unit/Prompts/ListPromptStateTests.cs | 2 +- 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index 76fa3f447..2ecd0bc8c 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -7,6 +7,11 @@ namespace Spectre.Console; internal interface IListPromptStrategy where T : notnull { + /// + /// Gets a value indicating whether or not the prompt should skip unselectable items. + /// + public bool ShouldSkipUnselectableItems { get; } + /// /// Handles any input received from the user. /// diff --git a/src/Spectre.Console/Prompts/List/ListPrompt.cs b/src/Spectre.Console/Prompts/List/ListPrompt.cs index a992bae0b..564070ea2 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -40,7 +40,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, searchFilterEnabled); + var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, _strategy.ShouldSkipUnselectableItems, searchFilterEnabled); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index ae82be138..4910e6cbc 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -8,23 +8,25 @@ internal sealed class ListPromptState public int PageSize { get; } public bool WrapAround { get; } public SelectionMode Mode { get; } + public bool SkipUnselectableItems { get; private set; } public bool SearchFilterEnabled { get; } public IReadOnlyList> Items { get; } private readonly IReadOnlyList? _leafIndexes; public ListPromptItem Current => Items[Index]; - public string SearchFilter { get; set; } + public string SearchFilter { get; private set; } - public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool searchFilterEnabled) + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchFilterEnabled) { Items = items; PageSize = pageSize; WrapAround = wrapAround; Mode = mode; + SkipUnselectableItems = skipUnselectableItems; SearchFilterEnabled = searchFilterEnabled; SearchFilter = string.Empty; - if (mode == SelectionMode.Leaf) + if (SkipUnselectableItems && mode == SelectionMode.Leaf) { _leafIndexes = Items @@ -45,7 +47,7 @@ public ListPromptState(IReadOnlyList> items, int pageSize, boo public bool Update(ConsoleKeyInfo keyInfo) { var index = Index; - if (Mode == SelectionMode.Leaf) + if (SkipUnselectableItems && Mode == SelectionMode.Leaf) { Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null"); var currentLeafIndex = _leafIndexes.IndexOf(index); diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index 773fddc4b..12b0fe971 100644 --- a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -145,6 +145,9 @@ public IEnumerable GetParents(T item) return GetParents(item).LastOrDefault(); } + /// + bool IListPromptStrategy.ShouldSkipUnselectableItems => false; + /// ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState state) { diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index abf98da4e..58f2f16ed 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -83,6 +83,9 @@ public ISelectionItem AddChoice(T item) return node; } + /// + bool IListPromptStrategy.ShouldSkipUnselectableItems => true; + /// public T Show(IAnsiConsole console) { @@ -128,17 +131,20 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem extra += 2; } - // Scrolling? - if (totalItemCount > requestedPageSize) + var scrollable = totalItemCount > requestedPageSize; + if (SearchFilterEnabled || scrollable) { - // The scrolling instructions takes up two rows - extra += 2; + extra += 1; } if (SearchFilterEnabled) { - // The search instructions takes up two rows - extra += 2; + extra += 1; + } + + if (scrollable) + { + extra += 1; } if (requestedPageSize > console.Profile.Height - extra) @@ -209,18 +215,22 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, list.Add(grid); - if (scrollable) + if (SearchFilterEnabled || scrollable) { - // (Move up and down to reveal more choices) + // Add padding list.Add(Text.Empty); - 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)}")); + stateSearchFilter.Length > 0 ? stateSearchFilter.EscapeMarkup() : ListPromptConstants.SearchPlaceholderMarkup)); + } + + if (scrollable) + { + // (Move up and down to reveal more choices) + list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup)); } return new Rows(list); diff --git a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index db3368886..e0b4fa0f1 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, SelectionMode.Independent, false); + => new(Enumerable.Repeat(new ListPromptItem(string.Empty), count).ToList(), pageSize, shouldWrap, SelectionMode.Independent, true, false); [Fact] public void Should_Have_Start_Index_Zero() From 65119c3198378aeff809a7cbcc63f10b4bb5e2ae Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 15 Sep 2023 13:41:12 +0100 Subject: [PATCH 10/17] Update API naming --- .../Prompts/List/IListPromptStrategy.cs | 4 +-- .../Prompts/List/ListPrompt.cs | 6 ++--- .../Prompts/List/ListPromptState.cs | 20 +++++++------- .../Prompts/MultiSelectionPrompt.cs | 2 +- .../Prompts/SelectionPrompt.cs | 26 +++++++++---------- .../Prompts/SelectionPromptExtensions.cs | 4 +-- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index 2ecd0bc8c..3fdbbdc34 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -36,8 +36,8 @@ internal interface IListPromptStrategy /// Whether or not the list is scrollable. /// The cursor index. /// The visible items. - /// The search filter. + /// The search text. /// A representing the items. public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, - IEnumerable<(int Index, ListPromptItem Node)> items, string searchFilter); + IEnumerable<(int Index, ListPromptItem Node)> items, 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 564070ea2..c8d845e02 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -15,7 +15,7 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) public async Task> Show( ListPromptTree tree, SelectionMode selectionMode, - bool searchFilterEnabled, + bool searchEnabled, int requestedPageSize, bool wrapAround, CancellationToken cancellationToken = default) @@ -40,7 +40,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, _strategy.ShouldSkipUnselectableItems, searchFilterEnabled); + var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, _strategy.ShouldSkipUnselectableItems, searchEnabled); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) @@ -113,6 +113,6 @@ private IRenderable BuildRenderable(ListPromptState state) scrollable, cursorIndex, state.Items.Skip(skip).Take(take) .Select((node, index) => (index, node)), - state.SearchFilter); + state.SearchText); } } \ 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 4910e6cbc..177b97edb 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -9,22 +9,22 @@ internal sealed class ListPromptState public bool WrapAround { get; } public SelectionMode Mode { get; } public bool SkipUnselectableItems { get; private set; } - public bool SearchFilterEnabled { get; } + public bool SearchEnabled { get; } public IReadOnlyList> Items { get; } private readonly IReadOnlyList? _leafIndexes; public ListPromptItem Current => Items[Index]; - public string SearchFilter { get; private set; } + public string SearchText { get; private set; } - public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchFilterEnabled) + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled) { Items = items; PageSize = pageSize; WrapAround = wrapAround; Mode = mode; SkipUnselectableItems = skipUnselectableItems; - SearchFilterEnabled = searchFilterEnabled; - SearchFilter = string.Empty; + SearchEnabled = searchEnabled; + SearchText = string.Empty; if (SkipUnselectableItems && mode == SelectionMode.Leaf) { @@ -118,14 +118,14 @@ public bool Update(ConsoleKeyInfo keyInfo) }; } - var search = SearchFilter; + var search = SearchText; - if (SearchFilterEnabled) + if (SearchEnabled) { // If is text input, append to search filter if (!char.IsControl(keyInfo.KeyChar)) { - search = SearchFilter + 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) { @@ -152,10 +152,10 @@ public bool Update(ConsoleKeyInfo keyInfo) ? (ItemCount + (index % ItemCount)) % ItemCount : index.Clamp(0, ItemCount - 1); - if (index != Index || SearchFilter != search) + if (index != Index || SearchText != search) { Index = index; - SearchFilter = search; + SearchText = search; return true; } diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index 12b0fe971..1af03b3a9 100644 --- a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -226,7 +226,7 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, - IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) + IEnumerable<(int Index, ListPromptItem Node)> items, 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 58f2f16ed..08f4333c9 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -59,9 +59,9 @@ 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. + /// Gets or sets a value indicating whether or not search is enabled. /// - public bool SearchFilterEnabled { get; set; } + public bool SearchEnabled { get; set; } /// /// Initializes a new instance of the class. @@ -97,7 +97,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, Mode, SearchFilterEnabled, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); + var result = await prompt.Show(_tree, Mode, SearchEnabled, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); // Return the selected item return result.Items[result.Index].Data; @@ -132,12 +132,12 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } var scrollable = totalItemCount > requestedPageSize; - if (SearchFilterEnabled || scrollable) + if (SearchEnabled || scrollable) { extra += 1; } - if (SearchFilterEnabled) + if (SearchEnabled) { extra += 1; } @@ -157,7 +157,7 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, - IEnumerable<(int Index, ListPromptItem Node)> items, string stateSearchFilter) + IEnumerable<(int Index, ListPromptItem Node)> items, string searchText) { var list = new List(); var disabledStyle = DisabledStyle ?? Color.Grey; @@ -193,14 +193,14 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, text = text.RemoveMarkup().EscapeMarkup(); } - if (stateSearchFilter.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf)) + if (searchText.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf)) { - var index = text.IndexOf(stateSearchFilter, StringComparison.OrdinalIgnoreCase); + var index = text.IndexOf(searchText, 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); + var match = text.Substring(index, searchText.Length); + var after = text.Substring(index + searchText.Length); text = new StringBuilder() .Append(before) @@ -215,16 +215,16 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, list.Add(grid); - if (SearchFilterEnabled || scrollable) + if (SearchEnabled || scrollable) { // Add padding list.Add(Text.Empty); } - if (SearchFilterEnabled) + if (SearchEnabled) { list.Add(new Markup( - stateSearchFilter.Length > 0 ? stateSearchFilter.EscapeMarkup() : ListPromptConstants.SearchPlaceholderMarkup)); + searchText.Length > 0 ? searchText.EscapeMarkup() : ListPromptConstants.SearchPlaceholderMarkup)); } if (scrollable) diff --git a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs index 40750c497..15cd4d72c 100644 --- a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -189,7 +189,7 @@ public static SelectionPrompt WrapAround(this SelectionPrompt obj, bool /// 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) + public static SelectionPrompt Search(this SelectionPrompt obj, bool enabled = true) where T : notnull { if (obj is null) @@ -197,7 +197,7 @@ public static SelectionPrompt SearchFilter(this SelectionPrompt obj, bo throw new ArgumentNullException(nameof(obj)); } - obj.SearchFilterEnabled = enabled; + obj.SearchEnabled = enabled; return obj; } From 42458835b070b52b7490be9643e3e9981d6d13a3 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 17 Sep 2023 00:41:36 +0100 Subject: [PATCH 11/17] Address feedback --- .../Prompts/List/IListPromptStrategy.cs | 8 +--- .../Prompts/List/ListPrompt.cs | 4 +- .../Prompts/MultiSelectionPrompt.cs | 7 +-- .../Prompts/SelectionPrompt.cs | 16 ++++--- .../Prompts/SelectionPromptExtensions.cs | 44 +++++++++++++++++-- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs index 3fdbbdc34..a49358c61 100644 --- a/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -7,11 +7,6 @@ namespace Spectre.Console; internal interface IListPromptStrategy where T : notnull { - /// - /// Gets a value indicating whether or not the prompt should skip unselectable items. - /// - public bool ShouldSkipUnselectableItems { get; } - /// /// Handles any input received from the user. /// @@ -36,8 +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, string searchText); + 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 c8d845e02..7f6948c1a 100644 --- a/src/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console/Prompts/List/ListPrompt.cs @@ -15,6 +15,7 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) public async Task> Show( ListPromptTree tree, SelectionMode selectionMode, + bool skipUnselectableItems, bool searchEnabled, int requestedPageSize, bool wrapAround, @@ -40,7 +41,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, _strategy.ShouldSkipUnselectableItems, searchEnabled); + 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)) @@ -113,6 +114,7 @@ private IRenderable BuildRenderable(ListPromptState state) scrollable, cursorIndex, state.Items.Skip(skip).Take(take) .Select((node, index) => (index, node)), + state.SkipUnselectableItems, state.SearchText); } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console/Prompts/MultiSelectionPrompt.cs index 1af03b3a9..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, Mode, false, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); + var result = await prompt.Show(Tree, Mode, false, false, PageSize, WrapAround, cancellationToken).ConfigureAwait(false); if (Mode == SelectionMode.Leaf) { @@ -145,9 +145,6 @@ public IEnumerable GetParents(T item) return GetParents(item).LastOrDefault(); } - /// - bool IListPromptStrategy.ShouldSkipUnselectableItems => false; - /// ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState state) { @@ -226,7 +223,7 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, - IEnumerable<(int Index, ListPromptItem Node)> items, string searchText) + 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 08f4333c9..15a7afdd8 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -41,6 +41,11 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// 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. @@ -83,9 +88,6 @@ public ISelectionItem AddChoice(T item) return node; } - /// - bool IListPromptStrategy.ShouldSkipUnselectableItems => true; - /// public T Show(IAnsiConsole console) { @@ -97,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, Mode, SearchEnabled, PageSize, WrapAround, cancellationToken).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; @@ -106,7 +108,7 @@ public async Task ShowAsync(IAnsiConsole console, CancellationToken cancellat /// ListPromptInputResult IListPromptStrategy.HandleInput(ConsoleKeyInfo key, ListPromptState state) { - if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Packet) + if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar || key.Key == ConsoleKey.Packet) { // Selecting a non leaf in "leaf mode" is not allowed if (state.Current.IsGroup && Mode == SelectionMode.Leaf) @@ -157,7 +159,7 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem /// IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, - IEnumerable<(int Index, ListPromptItem Node)> items, string searchText) + IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText) { var list = new List(); var disabledStyle = DisabledStyle ?? Color.Grey; @@ -224,7 +226,7 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, if (SearchEnabled) { list.Add(new Markup( - searchText.Length > 0 ? searchText.EscapeMarkup() : ListPromptConstants.SearchPlaceholderMarkup)); + searchText.Length > 0 ? searchText.EscapeMarkup() : SearchPlaceholderText ?? ListPromptConstants.SearchPlaceholderMarkup)); } if (scrollable) diff --git a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs index 15cd4d72c..a5d7be133 100644 --- a/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -183,13 +183,12 @@ public static SelectionPrompt WrapAround(this SelectionPrompt obj, bool } /// - /// Sets whether the search filter should be enabled. + /// Enables search for the prompt. /// /// 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 Search(this SelectionPrompt obj, bool enabled = true) + public static SelectionPrompt EnableSearch(this SelectionPrompt obj) where T : notnull { if (obj is null) @@ -197,7 +196,44 @@ public static SelectionPrompt Search(this SelectionPrompt obj, bool ena throw new ArgumentNullException(nameof(obj)); } - obj.SearchEnabled = enabled; + 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; } From 60dac04b16fda0fd705ad8cc582425559a9106e0 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 17 Sep 2023 01:16:29 +0100 Subject: [PATCH 12/17] Add some tests --- .../Unit/Prompts/ListPromptStateTests.cs | 47 +++++++++++++++---- .../Unit/Prompts/SelectionPromptTests.cs | 44 +++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index e0b4fa0f1..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, SelectionMode.Independent, true, false); + 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,7 +24,7 @@ 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 @@ -40,7 +40,7 @@ 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.ToConsoleKeyInfo()); @@ -53,7 +53,7 @@ public void Should_Go_To_End(bool wrap) public void Should_Clamp_Index_If_No_Wrap() { // Given - var state = CreateListPromptState(100, 10, false); + var state = CreateListPromptState(100, 10, false, false); state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When @@ -67,7 +67,7 @@ public void Should_Clamp_Index_If_No_Wrap() public void Should_Wrap_Index_If_Wrap() { // Given - var state = CreateListPromptState(100, 10, true); + var state = CreateListPromptState(100, 10, true, false); state.Update(ConsoleKey.End.ToConsoleKeyInfo()); // When @@ -81,7 +81,7 @@ 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.ToConsoleKeyInfo()); @@ -94,7 +94,7 @@ 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.ToConsoleKeyInfo()); @@ -107,7 +107,7 @@ 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); + var state = CreateListPromptState(10, 100, true, false); state.Update(ConsoleKey.End.ToConsoleKeyInfo()); state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo()); @@ -117,4 +117,31 @@ public void Should_Wrap_Index_If_Wrap_And_Offset_And_Page_Down() // 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..e01a50ba8 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs @@ -20,4 +20,48 @@ 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"); + } + + } From 0a5b8520a8d06432d3e54d65fdddad80014635cd Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 17 Sep 2023 01:51:28 +0100 Subject: [PATCH 13/17] Remove whitespace --- test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs index e01a50ba8..447a71d8e 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs @@ -62,6 +62,4 @@ public void Should_Select_The_Last_Leaf_Item_When_Wrapping_Around() // Then selection.ShouldBe("D"); } - - } From fb571b138dcc34c748e05eb528674efe4a1d9e01 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 24 Dec 2023 01:54:43 +0000 Subject: [PATCH 14/17] Improve search and highlighting --- .../Extensions/StringExtensions.cs | 94 +++++++++++++++++++ .../Prompts/SelectionPrompt.cs | 14 +-- .../Unit/HighlightTests.cs | 51 ++++++++++ .../Unit/Prompts/SelectionPromptTests.cs | 24 +++++ 4 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 test/Spectre.Console.Tests/Unit/HighlightTests.cs diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index 06d0554ef..0c324e4f1 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -209,4 +209,98 @@ public static string Mask(this string value, char? mask) 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; + } + + var foundSearchPattern = false; + var builder = new StringBuilder(); + using var tokenizer = new MarkupTokenizer(value); + while (tokenizer.MoveNext()) + { + 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 builder.ToString(); + } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console/Prompts/SelectionPrompt.cs index 15a7afdd8..e6ed46b87 100644 --- a/src/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console/Prompts/SelectionPrompt.cs @@ -197,19 +197,7 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, if (searchText.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf)) { - var index = text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase); - if (index >= 0) - { - var before = text.Substring(0, index); - var match = text.Substring(index, searchText.Length); - var after = text.Substring(index + searchText.Length); - - text = new StringBuilder() - .Append(before) - .AppendWithStyle(searchHighlightStyle, match) - .Append(after) - .ToString(); - } + text = text.Highlight(searchText, searchHighlightStyle); } grid.AddRow(new Markup(indent + prompt + " " + text, style)); diff --git a/test/Spectre.Console.Tests/Unit/HighlightTests.cs b/test/Spectre.Console.Tests/Unit/HighlightTests.cs new file mode 100644 index 000000000..01748e7cf --- /dev/null +++ b/test/Spectre.Console.Tests/Unit/HighlightTests.cs @@ -0,0 +1,51 @@ +using Spectre.Console; + +namespace Namespace; + +public class HighlightTests +{ + [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 = new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); + + // 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 = new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); + + // 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/SelectionPromptTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs index 447a71d8e..9edd50394 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() { @@ -62,4 +64,26 @@ public void Should_Select_The_Last_Leaf_Item_When_Wrapping_Around() // 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 + var consoleOutput = console.Output; + consoleOutput.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m"); + } } From 277b4e70bcb84a7a93f34a3c516da96044b6e8e8 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 24 Dec 2023 02:10:01 +0000 Subject: [PATCH 15/17] Add test case for previous issue --- .../Unit/HighlightTests.cs | 21 +++++++++++++++++-- .../Unit/Prompts/SelectionPromptTests.cs | 3 +-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/test/Spectre.Console.Tests/Unit/HighlightTests.cs b/test/Spectre.Console.Tests/Unit/HighlightTests.cs index 01748e7cf..800e99ec9 100644 --- a/test/Spectre.Console.Tests/Unit/HighlightTests.cs +++ b/test/Spectre.Console.Tests/Unit/HighlightTests.cs @@ -4,6 +4,8 @@ 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() { @@ -25,7 +27,7 @@ public void Should_Highlight_Matched_Text() // Given var value = "Sample text with test word"; var searchText = "test"; - var highlightStyle = new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); + var highlightStyle = _highlightStyle; // When var result = value.Highlight(searchText, highlightStyle); @@ -40,7 +42,22 @@ public void Should_Not_Match_Text_Across_Tokens() // Given var value = "[red]Sample text[/] with test word"; var searchText = "text with"; - var highlightStyle = new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold); + var highlightStyle = _highlightStyle; + + // When + var result = value.Highlight(searchText, highlightStyle); + + // Then + result.ShouldBe(value); + } + + [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); diff --git a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs index 9edd50394..5878c9df8 100644 --- a/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs +++ b/test/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs @@ -83,7 +83,6 @@ public void Should_Highlight_Search_Term() prompt.Show(console); // Then - var consoleOutput = console.Output; - consoleOutput.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m"); + console.Output.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m"); } } From f63dfbad4ddc21a36de3f9395b15be5f6706ef22 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sat, 30 Dec 2023 17:38:39 +0000 Subject: [PATCH 16/17] Add extra test case --- test/Spectre.Console.Tests/Unit/HighlightTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/Spectre.Console.Tests/Unit/HighlightTests.cs b/test/Spectre.Console.Tests/Unit/HighlightTests.cs index 800e99ec9..6575ddfd6 100644 --- a/test/Spectre.Console.Tests/Unit/HighlightTests.cs +++ b/test/Spectre.Console.Tests/Unit/HighlightTests.cs @@ -51,6 +51,21 @@ public void Should_Not_Match_Text_Across_Tokens() 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() { From 82e70bf00d8dd7d1add1652e1c8e95d6cbfd4cd0 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 25 Feb 2024 12:54:36 +0100 Subject: [PATCH 17/17] Make prompt searchable --- examples/Console/Prompt/Program.cs | 1 + 1 file changed, 1 insertion(+) 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));