Skip to content

Commit

Permalink
Add selection orompt Search (#1289)
Browse files Browse the repository at this point in the history
* Add selection prompt search as you type

* Fix small bug

* Simplify

* Simplify

* Remove spacebar as a selection prompt submit key

* Trigger CI

* Update src/Spectre.Console/Prompts/SelectionPrompt.cs

Co-authored-by: Martin Costello <martin@martincostello.com>

* Simplifty Mask method

* Handle multi-selection prompt better

* Update API naming

* Address feedback

* Add some tests

* Remove whitespace

* Improve search and highlighting

* Add test case for previous issue

* Add extra test case

* Make prompt searchable

---------

Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Patrik Svensson <patrik@patriksvensson.se>
  • Loading branch information
3 people authored Feb 25, 2024
1 parent d30b082 commit 397b742
Show file tree
Hide file tree
Showing 14 changed files with 567 additions and 58 deletions.
1 change: 1 addition & 0 deletions examples/Console/Prompt/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ public static string AskFruit()
{
fruit = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.EnableSearch()
.Title("Ok, but if you could only choose [green]one[/]?")
.MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]")
.AddChoices(favorites));
Expand Down
106 changes: 100 additions & 6 deletions src/Spectre.Console/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

/// <summary>
/// "Masks" every character in a string.
/// </summary>
Expand All @@ -195,18 +202,105 @@ internal static bool ContainsExact(this string text, string value)
/// <returns>Masked string.</returns>
public static string Mask(this string value, char? mask)
{
var output = string.Empty;

if (mask is null)
{
return output;
return string.Empty;
}

return new string(mask.Value, value.Length);
}

/// <summary>
/// Highlights the first text match in provided value.
/// </summary>
/// <param name="value">Input value.</param>
/// <param name="searchText">Text to search for.</param>
/// <param name="highlightStyle">The style to apply to the matched text.</param>
/// <returns>Markup of input with the first matched text highlighted.</returns>
internal static string Highlight(this string value, string searchText, Style? highlightStyle)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}

if (searchText is null)
{
throw new ArgumentNullException(nameof(searchText));
}

if (highlightStyle is null)
{
throw new ArgumentNullException(nameof(highlightStyle));
}

if (searchText.Length == 0)
{
return value;
}

foreach (var c in value)
var foundSearchPattern = false;
var builder = new StringBuilder();
using var tokenizer = new MarkupTokenizer(value);
while (tokenizer.MoveNext())
{
output += mask;
var token = tokenizer.Current!;

switch (token.Kind)
{
case MarkupTokenKind.Text:
{
var tokenValue = token.Value;
if (tokenValue.Length == 0)
{
break;
}

if (foundSearchPattern)
{
builder.Append(tokenValue);
break;
}

var index = tokenValue.IndexOf(searchText, StringComparison.OrdinalIgnoreCase);
if (index == -1)
{
builder.Append(tokenValue);
break;
}

foundSearchPattern = true;
var before = tokenValue.Substring(0, index);
var match = tokenValue.Substring(index, searchText.Length);
var after = tokenValue.Substring(index + searchText.Length);

builder
.Append(before)
.AppendWithStyle(highlightStyle, match)
.Append(after);

break;
}

case MarkupTokenKind.Open:
{
builder.Append("[" + token.Value + "]");
break;
}

case MarkupTokenKind.Close:
{
builder.Append("[/]");
break;
}

default:
{
throw new InvalidOperationException("Unknown markup token kind.");
}
}
}

return output;
return builder.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ public static IEnumerable<T> Repeat<T>(this IEnumerable<T> source, int count)
}

public static int IndexOf<T>(this IEnumerable<T> source, T item)
where T : class
{
var index = 0;
foreach (var candidate in source)
{
if (candidate == item)
if (Equals(candidate, item))
{
return index;
}
Expand Down
5 changes: 4 additions & 1 deletion src/Spectre.Console/Prompts/List/IListPromptStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ internal interface IListPromptStrategy<T>
/// <param name="scrollable">Whether or not the list is scrollable.</param>
/// <param name="cursorIndex">The cursor index.</param>
/// <param name="items">The visible items.</param>
/// <param name="skipUnselectableItems">A value indicating whether or not the prompt should skip unselectable items.</param>
/// <param name="searchText">The search text.</param>
/// <returns>A <see cref="IRenderable"/> representing the items.</returns>
public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items);
public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex,
IEnumerable<(int Index, ListPromptItem<T> Node)> items, bool skipUnselectableItems, string searchText);
}
17 changes: 11 additions & 6 deletions src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ public ListPrompt(IAnsiConsole console, IListPromptStrategy<T> strategy)

public async Task<ListPromptState<T>> Show(
ListPromptTree<T> tree,
CancellationToken cancellationToken,
int requestedPageSize = 15,
bool wrapAround = false)
SelectionMode selectionMode,
bool skipUnselectableItems,
bool searchEnabled,
int requestedPageSize,
bool wrapAround,
CancellationToken cancellationToken = default)
{
if (tree is null)
{
Expand All @@ -38,7 +41,7 @@ public async Task<ListPromptState<T>> Show(
}

var nodes = tree.Traverse().ToList();
var state = new ListPromptState<T>(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround);
var state = new ListPromptState<T>(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled);
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));

using (new RenderHookScope(_console, hook))
Expand All @@ -62,7 +65,7 @@ public async Task<ListPromptState<T>> Show(
break;
}

if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
if (state.Update(key) || result == ListPromptInputResult.Refresh)
{
hook.Refresh();
}
Expand Down Expand Up @@ -110,6 +113,8 @@ private IRenderable BuildRenderable(ListPromptState<T> state)
_console,
scrollable, cursorIndex,
state.Items.Skip(skip).Take(take)
.Select((node, index) => (index, node)));
.Select((node, index) => (index, node)),
state.SkipUnselectableItems,
state.SearchText);
}
}
1 change: 1 addition & 0 deletions src/Spectre.Console/Prompts/List/ListPromptConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ internal sealed class ListPromptConstants
public const string GroupSelectedCheckbox = "[[[grey]X[/]]]";
public const string InstructionsMarkup = "[grey](Press <space> to select, <enter> to accept)[/]";
public const string MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]";
public const string SearchPlaceholderMarkup = "[grey](Type to search)[/]";
}
144 changes: 131 additions & 13 deletions src/Spectre.Console/Prompts/List/ListPromptState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,155 @@ internal sealed class ListPromptState<T>
public int ItemCount => Items.Count;
public int PageSize { get; }
public bool WrapAround { get; }
public SelectionMode Mode { get; }
public bool SkipUnselectableItems { get; private set; }
public bool SearchEnabled { get; }
public IReadOnlyList<ListPromptItem<T>> Items { get; }
private readonly IReadOnlyList<int>? _leafIndexes;

public ListPromptItem<T> Current => Items[Index];
public string SearchText { get; private set; }

public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, bool wrapAround)
public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled)
{
Index = 0;
Items = items;
PageSize = pageSize;
WrapAround = wrapAround;
Mode = mode;
SkipUnselectableItems = skipUnselectableItems;
SearchEnabled = searchEnabled;
SearchText = string.Empty;

if (SkipUnselectableItems && mode == SelectionMode.Leaf)
{
_leafIndexes =
Items
.Select((item, index) => new { item, index })
.Where(x => !x.item.IsGroup)
.Select(x => x.index)
.ToList()
.AsReadOnly();

Index = _leafIndexes.FirstOrDefault();
}
else
{
Index = 0;
}
}

public bool Update(ConsoleKey key)
public bool Update(ConsoleKeyInfo keyInfo)
{
var index = key switch
var index = Index;
if (SkipUnselectableItems && Mode == SelectionMode.Leaf)
{
ConsoleKey.UpArrow => Index - 1,
ConsoleKey.DownArrow => Index + 1,
ConsoleKey.Home => 0,
ConsoleKey.End => ItemCount - 1,
ConsoleKey.PageUp => Index - PageSize,
ConsoleKey.PageDown => Index + PageSize,
_ => Index,
};
Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null");
var currentLeafIndex = _leafIndexes.IndexOf(index);
switch (keyInfo.Key)
{
case ConsoleKey.UpArrow:
if (currentLeafIndex > 0)
{
index = _leafIndexes[currentLeafIndex - 1];
}
else if (WrapAround)
{
index = _leafIndexes.LastOrDefault();
}

break;

case ConsoleKey.DownArrow:
if (currentLeafIndex < _leafIndexes.Count - 1)
{
index = _leafIndexes[currentLeafIndex + 1];
}
else if (WrapAround)
{
index = _leafIndexes.FirstOrDefault();
}

break;

case ConsoleKey.Home:
index = _leafIndexes.FirstOrDefault();
break;

case ConsoleKey.End:
index = _leafIndexes.LastOrDefault();
break;

case ConsoleKey.PageUp:
index = Math.Max(currentLeafIndex - PageSize, 0);
if (index < _leafIndexes.Count)
{
index = _leafIndexes[index];
}

break;

case ConsoleKey.PageDown:
index = Math.Min(currentLeafIndex + PageSize, _leafIndexes.Count - 1);
if (index < _leafIndexes.Count)
{
index = _leafIndexes[index];
}

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

var search = SearchText;

if (SearchEnabled)
{
// If is text input, append to search filter
if (!char.IsControl(keyInfo.KeyChar))
{
search = SearchText + keyInfo.KeyChar;
var item = Items.FirstOrDefault(x => x.Data.ToString()?.Contains(search, StringComparison.OrdinalIgnoreCase) == true && (!x.IsGroup || Mode != SelectionMode.Leaf));
if (item != null)
{
index = Items.IndexOf(item);
}
}

if (keyInfo.Key == ConsoleKey.Backspace)
{
if (search.Length > 0)
{
search = search.Substring(0, search.Length - 1);
}

var item = Items.FirstOrDefault(x => x.Data.ToString()?.Contains(search, StringComparison.OrdinalIgnoreCase) == true && (!x.IsGroup || Mode != SelectionMode.Leaf));
if (item != null)
{
index = Items.IndexOf(item);
}
}
}

index = WrapAround
? (ItemCount + (index % ItemCount)) % ItemCount
: index.Clamp(0, ItemCount - 1);
if (index != Index)

if (index != Index || SearchText != search)
{
Index = index;
SearchText = search;
return true;
}

Expand Down
Loading

0 comments on commit 397b742

Please sign in to comment.