mirror of
https://github.com/nsnail/spectre.console.git
synced 2025-04-16 08:52:50 +08:00
Add selection orompt Search (#1289)
* 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>
This commit is contained in:
parent
d30b08201d
commit
397b742bec
@ -110,6 +110,7 @@ namespace Prompt
|
|||||||
{
|
{
|
||||||
fruit = AnsiConsole.Prompt(
|
fruit = AnsiConsole.Prompt(
|
||||||
new SelectionPrompt<string>()
|
new SelectionPrompt<string>()
|
||||||
|
.EnableSearch()
|
||||||
.Title("Ok, but if you could only choose [green]one[/]?")
|
.Title("Ok, but if you could only choose [green]one[/]?")
|
||||||
.MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]")
|
.MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]")
|
||||||
.AddChoices(favorites));
|
.AddChoices(favorites));
|
||||||
|
@ -187,6 +187,13 @@ public static class StringExtensions
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if NETSTANDARD2_0
|
||||||
|
internal static bool Contains(this string target, string value, System.StringComparison comparisonType)
|
||||||
|
{
|
||||||
|
return target.IndexOf(value, comparisonType) != -1;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "Masks" every character in a string.
|
/// "Masks" every character in a string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -195,18 +202,105 @@ public static class StringExtensions
|
|||||||
/// <returns>Masked string.</returns>
|
/// <returns>Masked string.</returns>
|
||||||
public static string Mask(this string value, char? mask)
|
public static string Mask(this string value, char? mask)
|
||||||
{
|
{
|
||||||
var output = string.Empty;
|
|
||||||
|
|
||||||
if (mask is null)
|
if (mask is null)
|
||||||
{
|
{
|
||||||
return output;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var c in value)
|
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)
|
||||||
{
|
{
|
||||||
output += mask;
|
throw new ArgumentNullException(nameof(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -31,12 +31,11 @@ internal static class EnumerableExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static int IndexOf<T>(this IEnumerable<T> source, T item)
|
public static int IndexOf<T>(this IEnumerable<T> source, T item)
|
||||||
where T : class
|
|
||||||
{
|
{
|
||||||
var index = 0;
|
var index = 0;
|
||||||
foreach (var candidate in source)
|
foreach (var candidate in source)
|
||||||
{
|
{
|
||||||
if (candidate == item)
|
if (Equals(candidate, item))
|
||||||
{
|
{
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,9 @@ internal interface IListPromptStrategy<T>
|
|||||||
/// <param name="scrollable">Whether or not the list is scrollable.</param>
|
/// <param name="scrollable">Whether or not the list is scrollable.</param>
|
||||||
/// <param name="cursorIndex">The cursor index.</param>
|
/// <param name="cursorIndex">The cursor index.</param>
|
||||||
/// <param name="items">The visible items.</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>
|
/// <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);
|
||||||
}
|
}
|
@ -14,9 +14,12 @@ internal sealed class ListPrompt<T>
|
|||||||
|
|
||||||
public async Task<ListPromptState<T>> Show(
|
public async Task<ListPromptState<T>> Show(
|
||||||
ListPromptTree<T> tree,
|
ListPromptTree<T> tree,
|
||||||
CancellationToken cancellationToken,
|
SelectionMode selectionMode,
|
||||||
int requestedPageSize = 15,
|
bool skipUnselectableItems,
|
||||||
bool wrapAround = false)
|
bool searchEnabled,
|
||||||
|
int requestedPageSize,
|
||||||
|
bool wrapAround,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (tree is null)
|
if (tree is null)
|
||||||
{
|
{
|
||||||
@ -38,7 +41,7 @@ internal sealed class ListPrompt<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var nodes = tree.Traverse().ToList();
|
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));
|
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));
|
||||||
|
|
||||||
using (new RenderHookScope(_console, hook))
|
using (new RenderHookScope(_console, hook))
|
||||||
@ -62,7 +65,7 @@ internal sealed class ListPrompt<T>
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
|
if (state.Update(key) || result == ListPromptInputResult.Refresh)
|
||||||
{
|
{
|
||||||
hook.Refresh();
|
hook.Refresh();
|
||||||
}
|
}
|
||||||
@ -110,6 +113,8 @@ internal sealed class ListPrompt<T>
|
|||||||
_console,
|
_console,
|
||||||
scrollable, cursorIndex,
|
scrollable, cursorIndex,
|
||||||
state.Items.Skip(skip).Take(take)
|
state.Items.Skip(skip).Take(take)
|
||||||
.Select((node, index) => (index, node)));
|
.Select((node, index) => (index, node)),
|
||||||
|
state.SkipUnselectableItems,
|
||||||
|
state.SearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,4 +8,5 @@ internal sealed class ListPromptConstants
|
|||||||
public const string GroupSelectedCheckbox = "[[[grey]X[/]]]";
|
public const string GroupSelectedCheckbox = "[[[grey]X[/]]]";
|
||||||
public const string InstructionsMarkup = "[grey](Press <space> to select, <enter> to accept)[/]";
|
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 MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]";
|
||||||
|
public const string SearchPlaceholderMarkup = "[grey](Type to search)[/]";
|
||||||
}
|
}
|
@ -7,37 +7,155 @@ internal sealed class ListPromptState<T>
|
|||||||
public int ItemCount => Items.Count;
|
public int ItemCount => Items.Count;
|
||||||
public int PageSize { get; }
|
public int PageSize { get; }
|
||||||
public bool WrapAround { 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; }
|
public IReadOnlyList<ListPromptItem<T>> Items { get; }
|
||||||
|
private readonly IReadOnlyList<int>? _leafIndexes;
|
||||||
|
|
||||||
public ListPromptItem<T> Current => Items[Index];
|
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;
|
Items = items;
|
||||||
PageSize = pageSize;
|
PageSize = pageSize;
|
||||||
WrapAround = wrapAround;
|
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,
|
Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null");
|
||||||
ConsoleKey.DownArrow => Index + 1,
|
var currentLeafIndex = _leafIndexes.IndexOf(index);
|
||||||
ConsoleKey.Home => 0,
|
switch (keyInfo.Key)
|
||||||
ConsoleKey.End => ItemCount - 1,
|
{
|
||||||
ConsoleKey.PageUp => Index - PageSize,
|
case ConsoleKey.UpArrow:
|
||||||
ConsoleKey.PageDown => Index + PageSize,
|
if (currentLeafIndex > 0)
|
||||||
_ => Index,
|
{
|
||||||
};
|
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
|
index = WrapAround
|
||||||
? (ItemCount + (index % ItemCount)) % ItemCount
|
? (ItemCount + (index % ItemCount)) % ItemCount
|
||||||
: index.Clamp(0, ItemCount - 1);
|
: index.Clamp(0, ItemCount - 1);
|
||||||
if (index != Index)
|
|
||||||
|
if (index != Index || SearchText != search)
|
||||||
{
|
{
|
||||||
Index = index;
|
Index = index;
|
||||||
|
SearchText = search;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrat
|
|||||||
{
|
{
|
||||||
// Create the list prompt
|
// Create the list prompt
|
||||||
var prompt = new ListPrompt<T>(console, this);
|
var prompt = new ListPrompt<T>(console, this);
|
||||||
var result = await prompt.Show(Tree, cancellationToken, PageSize, WrapAround).ConfigureAwait(false);
|
var result = await prompt.Show(Tree, Mode, false, false, PageSize, WrapAround, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (Mode == SelectionMode.Leaf)
|
if (Mode == SelectionMode.Leaf)
|
||||||
{
|
{
|
||||||
@ -222,7 +222,8 @@ public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
|
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex,
|
||||||
|
IEnumerable<(int Index, ListPromptItem<T> Node)> items, bool skipUnselectableItems, string searchText)
|
||||||
{
|
{
|
||||||
var list = new List<IRenderable>();
|
var list = new List<IRenderable>();
|
||||||
var highlightStyle = HighlightStyle ?? Color.Blue;
|
var highlightStyle = HighlightStyle ?? Color.Blue;
|
||||||
|
@ -36,6 +36,16 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Style? DisabledStyle { get; set; }
|
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 text that will be displayed when no search text has been entered.
|
||||||
|
/// </summary>
|
||||||
|
public string? SearchPlaceholderText { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the converter to get the display string for a choice. By default
|
/// Gets or sets the converter to get the display string for a choice. By default
|
||||||
/// the corresponding <see cref="TypeConverter"/> is used.
|
/// the corresponding <see cref="TypeConverter"/> is used.
|
||||||
@ -53,6 +63,11 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
|
public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether or not search is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool SearchEnabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
|
/// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -84,7 +99,7 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
{
|
{
|
||||||
// Create the list prompt
|
// Create the list prompt
|
||||||
var prompt = new ListPrompt<T>(console, this);
|
var prompt = new ListPrompt<T>(console, this);
|
||||||
var result = await prompt.Show(_tree, cancellationToken, PageSize, WrapAround).ConfigureAwait(false);
|
var result = await prompt.Show(_tree, Mode, true, SearchEnabled, PageSize, WrapAround, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Return the selected item
|
// Return the selected item
|
||||||
return result.Items[result.Index].Data;
|
return result.Items[result.Index].Data;
|
||||||
@ -118,11 +133,20 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
extra += 2;
|
extra += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrolling?
|
var scrollable = totalItemCount > requestedPageSize;
|
||||||
if (totalItemCount > requestedPageSize)
|
if (SearchEnabled || scrollable)
|
||||||
{
|
{
|
||||||
// The scrolling instructions takes up two rows
|
extra += 1;
|
||||||
extra += 2;
|
}
|
||||||
|
|
||||||
|
if (SearchEnabled)
|
||||||
|
{
|
||||||
|
extra += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollable)
|
||||||
|
{
|
||||||
|
extra += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedPageSize > console.Profile.Height - extra)
|
if (requestedPageSize > console.Profile.Height - extra)
|
||||||
@ -134,11 +158,13 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
|
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex,
|
||||||
|
IEnumerable<(int Index, ListPromptItem<T> Node)> items, bool skipUnselectableItems, string searchText)
|
||||||
{
|
{
|
||||||
var list = new List<IRenderable>();
|
var list = new List<IRenderable>();
|
||||||
var disabledStyle = DisabledStyle ?? Color.Grey;
|
var disabledStyle = DisabledStyle ?? Color.Grey;
|
||||||
var highlightStyle = HighlightStyle ?? Color.Blue;
|
var highlightStyle = HighlightStyle ?? Color.Blue;
|
||||||
|
var searchHighlightStyle = SearchHighlightStyle ?? new Style(foreground: Color.Default, background: Color.Yellow, Decoration.Bold);
|
||||||
|
|
||||||
if (Title != null)
|
if (Title != null)
|
||||||
{
|
{
|
||||||
@ -169,15 +195,31 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
|
|||||||
text = text.RemoveMarkup().EscapeMarkup();
|
text = text.RemoveMarkup().EscapeMarkup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchText.Length > 0 && !(item.Node.IsGroup && Mode == SelectionMode.Leaf))
|
||||||
|
{
|
||||||
|
text = text.Highlight(searchText, searchHighlightStyle);
|
||||||
|
}
|
||||||
|
|
||||||
grid.AddRow(new Markup(indent + prompt + " " + text, style));
|
grid.AddRow(new Markup(indent + prompt + " " + text, style));
|
||||||
}
|
}
|
||||||
|
|
||||||
list.Add(grid);
|
list.Add(grid);
|
||||||
|
|
||||||
|
if (SearchEnabled || scrollable)
|
||||||
|
{
|
||||||
|
// Add padding
|
||||||
|
list.Add(Text.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SearchEnabled)
|
||||||
|
{
|
||||||
|
list.Add(new Markup(
|
||||||
|
searchText.Length > 0 ? searchText.EscapeMarkup() : SearchPlaceholderText ?? ListPromptConstants.SearchPlaceholderMarkup));
|
||||||
|
}
|
||||||
|
|
||||||
if (scrollable)
|
if (scrollable)
|
||||||
{
|
{
|
||||||
// (Move up and down to reveal more choices)
|
// (Move up and down to reveal more choices)
|
||||||
list.Add(Text.Empty);
|
|
||||||
list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
|
list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,6 +182,61 @@ public static class SelectionPromptExtensions
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enables search for the prompt.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The prompt result type.</typeparam>
|
||||||
|
/// <param name="obj">The prompt.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static SelectionPrompt<T> EnableSearch<T>(this SelectionPrompt<T> obj)
|
||||||
|
where T : notnull
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.SearchEnabled = true;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disables search for the prompt.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The prompt result type.</typeparam>
|
||||||
|
/// <param name="obj">The prompt.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static SelectionPrompt<T> DisableSearch<T>(this SelectionPrompt<T> obj)
|
||||||
|
where T : notnull
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.SearchEnabled = false;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the text that will be displayed when no search text has been entered.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The prompt result type.</typeparam>
|
||||||
|
/// <param name="obj">The prompt.</param>
|
||||||
|
/// <param name="text">The text to display.</param>
|
||||||
|
/// <returns>The same instance so that multiple calls can be chained.</returns>
|
||||||
|
public static SelectionPrompt<T> SearchPlaceholderText<T>(this SelectionPrompt<T> obj, string? text)
|
||||||
|
where T : notnull
|
||||||
|
{
|
||||||
|
if (obj is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.SearchPlaceholderText = text;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the highlight style of the selected choice.
|
/// Sets the highlight style of the selected choice.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
83
test/Spectre.Console.Tests/Unit/HighlightTests.cs
Normal file
83
test/Spectre.Console.Tests/Unit/HighlightTests.cs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// 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 = _highlightStyle;
|
||||||
|
|
||||||
|
// 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 = _highlightStyle;
|
||||||
|
|
||||||
|
// When
|
||||||
|
var result = value.Highlight(searchText, highlightStyle);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
var value = "[red]Sample text with test word[/]";
|
||||||
|
var searchText = "red";
|
||||||
|
var highlightStyle = _highlightStyle;
|
||||||
|
|
||||||
|
// When
|
||||||
|
var result = value.Highlight(searchText, highlightStyle);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
result.ShouldBe(value);
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,14 @@ namespace Spectre.Console.Tests.Unit;
|
|||||||
|
|
||||||
public sealed class ListPromptStateTests
|
public sealed class ListPromptStateTests
|
||||||
{
|
{
|
||||||
private ListPromptState<string> CreateListPromptState(int count, int pageSize, bool shouldWrap)
|
private ListPromptState<string> CreateListPromptState(int count, int pageSize, bool shouldWrap, bool searchEnabled)
|
||||||
=> new(Enumerable.Repeat(new ListPromptItem<string>(string.Empty), count).ToList(), pageSize, shouldWrap);
|
=> new(Enumerable.Range(0, count).Select(i => new ListPromptItem<string>(i.ToString())).ToList(), pageSize, shouldWrap, SelectionMode.Independent, true, searchEnabled);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Should_Have_Start_Index_Zero()
|
public void Should_Have_Start_Index_Zero()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, false);
|
var state = CreateListPromptState(100, 10, false, false);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
/* noop */
|
/* noop */
|
||||||
@ -24,11 +24,11 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Increase_Index(bool wrap)
|
public void Should_Increase_Index(bool wrap)
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, wrap);
|
var state = CreateListPromptState(100, 10, wrap, false);
|
||||||
var index = state.Index;
|
var index = state.Index;
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.DownArrow);
|
state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(index + 1);
|
state.Index.ShouldBe(index + 1);
|
||||||
@ -40,10 +40,10 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Go_To_End(bool wrap)
|
public void Should_Go_To_End(bool wrap)
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, wrap);
|
var state = CreateListPromptState(100, 10, wrap, false);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.End);
|
state.Update(ConsoleKey.End.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(99);
|
state.Index.ShouldBe(99);
|
||||||
@ -53,11 +53,11 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Clamp_Index_If_No_Wrap()
|
public void Should_Clamp_Index_If_No_Wrap()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, false);
|
var state = CreateListPromptState(100, 10, false, false);
|
||||||
state.Update(ConsoleKey.End);
|
state.Update(ConsoleKey.End.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.DownArrow);
|
state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(99);
|
state.Index.ShouldBe(99);
|
||||||
@ -67,11 +67,11 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Wrap_Index_If_Wrap()
|
public void Should_Wrap_Index_If_Wrap()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, true);
|
var state = CreateListPromptState(100, 10, true, false);
|
||||||
state.Update(ConsoleKey.End);
|
state.Update(ConsoleKey.End.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.DownArrow);
|
state.Update(ConsoleKey.DownArrow.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(0);
|
state.Index.ShouldBe(0);
|
||||||
@ -81,10 +81,10 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Wrap_Index_If_Wrap_And_Down()
|
public void Should_Wrap_Index_If_Wrap_And_Down()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(100, 10, true);
|
var state = CreateListPromptState(100, 10, true, false);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.UpArrow);
|
state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(99);
|
state.Index.ShouldBe(99);
|
||||||
@ -94,10 +94,10 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Wrap_Index_If_Wrap_And_Page_Up()
|
public void Should_Wrap_Index_If_Wrap_And_Page_Up()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(10, 100, true);
|
var state = CreateListPromptState(10, 100, true, false);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.PageUp);
|
state.Update(ConsoleKey.PageUp.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(0);
|
state.Index.ShouldBe(0);
|
||||||
@ -107,14 +107,41 @@ public sealed class ListPromptStateTests
|
|||||||
public void Should_Wrap_Index_If_Wrap_And_Offset_And_Page_Down()
|
public void Should_Wrap_Index_If_Wrap_And_Offset_And_Page_Down()
|
||||||
{
|
{
|
||||||
// Given
|
// Given
|
||||||
var state = CreateListPromptState(10, 100, true);
|
var state = CreateListPromptState(10, 100, true, false);
|
||||||
state.Update(ConsoleKey.End);
|
state.Update(ConsoleKey.End.ToConsoleKeyInfo());
|
||||||
state.Update(ConsoleKey.UpArrow);
|
state.Update(ConsoleKey.UpArrow.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// When
|
// When
|
||||||
state.Update(ConsoleKey.PageDown);
|
state.Update(ConsoleKey.PageDown.ToConsoleKeyInfo());
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
state.Index.ShouldBe(8);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ namespace Spectre.Console.Tests.Unit;
|
|||||||
|
|
||||||
public sealed class SelectionPromptTests
|
public sealed class SelectionPromptTests
|
||||||
{
|
{
|
||||||
|
private const string ESC = "\u001b";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Should_Not_Throw_When_Selecting_An_Item_With_Escaped_Markup()
|
public void Should_Not_Throw_When_Selecting_An_Item_With_Escaped_Markup()
|
||||||
{
|
{
|
||||||
@ -20,4 +22,67 @@ public sealed class SelectionPromptTests
|
|||||||
// Then
|
// Then
|
||||||
console.Output.ShouldContain(@"[red]This text will never be red[/]");
|
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<string>()
|
||||||
|
.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<string>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string>()
|
||||||
|
.Title("Select one")
|
||||||
|
.EnableSearch()
|
||||||
|
.AddChoices("Item 1");
|
||||||
|
prompt.Show(console);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
console.Output.ShouldContain($"{ESC}[38;5;12m> Item {ESC}[0m{ESC}[1;38;5;12;48;5;11m1{ESC}[0m");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user