Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selection Prompt Search #1289

Merged
merged 17 commits into from
Feb 25, 2024
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