Skip to content

Commit

Permalink
Add selection prompt search as you type
Browse files Browse the repository at this point in the history
  • Loading branch information
slang25 committed Sep 3, 2023
1 parent 813a53c commit 27d4efd
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 56 deletions.
53 changes: 30 additions & 23 deletions src/Spectre.Console/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,28 +185,35 @@ internal static bool ContainsExact(this string text, string value)
#else
return text.Contains(value, StringComparison.Ordinal);
#endif
}

/// <summary>
/// "Masks" every character in a string.
/// </summary>
/// <param name="value">String value to mask.</param>
/// <param name="mask">Character to use for masking.</param>
/// <returns>Masked string.</returns>
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)
{

Check failure on line 192 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 192 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 192 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (macOS)

return target.IndexOf(value, comparisonType) != -1;

Check failure on line 193 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 193 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 193 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (macOS)

}

Check failure on line 194 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 194 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (linux)

Check failure on line 194 in src/Spectre.Console/Extensions/StringExtensions.cs

View workflow job for this annotation

GitHub Actions / Build (macOS)

#endif

/// <summary>
/// "Masks" every character in a string.
/// </summary>
/// <param name="value">String value to mask.</param>
/// <param name="mask">Character to use for masking.</param>
/// <returns>Masked string.</returns>
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;
}
}
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
10 changes: 9 additions & 1 deletion src/Spectre.Console/Prompts/List/IListPromptStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,21 @@ internal interface IListPromptStrategy<T>
/// <returns>The page size that should be used.</returns>
public int CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize);

/// <summary>
/// Gets the selection mode.
/// </summary>
/// <returns>The selection mode.</returns>
public SelectionMode GetSelectionMode();

/// <summary>
/// Builds a <see cref="IRenderable"/> from the current state.
/// </summary>
/// <param name="console">The console.</param>
/// <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="searchFilter">The search filter.</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, string searchFilter);
}
7 changes: 4 additions & 3 deletions src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,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, _strategy.GetSelectionMode());
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));

using (new RenderHookScope(_console, hook))
Expand All @@ -61,7 +61,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 @@ -109,6 +109,7 @@ 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.SearchFilter);
}
}
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)[/]";
}
128 changes: 115 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,139 @@ internal sealed class ListPromptState<T>
public int ItemCount => Items.Count;
public int PageSize { get; }
public bool WrapAround { get; }
public SelectionMode Mode { get; }
public IReadOnlyList<ListPromptItem<T>> Items { get; }
private readonly IReadOnlyList<int> _leafIndexes;

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

public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize, bool wrapAround)
public ListPromptState(IReadOnlyList<ListPromptItem<T>> 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;
}

Expand Down
9 changes: 8 additions & 1 deletion src/Spectre.Console/Prompts/MultiSelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,14 @@ int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItem
}

/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
public SelectionMode GetSelectionMode()
{
return this.Mode;
}

/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex,
IEnumerable<(int Index, ListPromptItem<T> Node)> items, string stateSearchFilter)
{
var list = new List<IRenderable>();
var highlightStyle = HighlightStyle ?? Color.Blue;
Expand Down
44 changes: 43 additions & 1 deletion src/Spectre.Console/Prompts/SelectionPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
/// </summary>
public Style? DisabledStyle { get; set; }

/// <summary>
/// Gets or sets the style of highlighted search matches.
/// </summary>
public Style? SearchHighlightStyle { get; set; }

/// <summary>
/// Gets or sets the converter to get the display string for a choice. By default
/// the corresponding <see cref="TypeConverter"/> is used.
Expand All @@ -53,6 +58,11 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
/// </summary>
public SelectionMode Mode { get; set; } = SelectionMode.Leaf;

/// <summary>
/// Gets or sets a value indicating whether or not the search filter is enabled.
/// </summary>
public bool SearchFilterEnabled { get; set; } = false;

/// <summary>
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
/// </summary>
Expand Down Expand Up @@ -134,11 +144,19 @@ int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItem
}

/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
public SelectionMode GetSelectionMode()
{
return this.Mode;
}

/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex,
IEnumerable<(int Index, ListPromptItem<T> Node)> items, string stateSearchFilter)
{
var list = new List<IRenderable>();
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)
{
Expand Down Expand Up @@ -169,6 +187,23 @@ IRenderable IListPromptStrategy<T>.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));
}

Expand All @@ -181,6 +216,13 @@ IRenderable IListPromptStrategy<T>.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);
}
}
Loading

0 comments on commit 27d4efd

Please sign in to comment.