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);