mirror of
				https://github.com/nsnail/spectre.console.git
				synced 2025-11-04 10:35:27 +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:
		@@ -187,6 +187,13 @@ public static class StringExtensions
 | 
			
		||||
#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>
 | 
			
		||||
@@ -195,18 +202,105 @@ public static class StringExtensions
 | 
			
		||||
    /// <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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
        where T : class
 | 
			
		||||
    {
 | 
			
		||||
        var index = 0;
 | 
			
		||||
        foreach (var candidate in source)
 | 
			
		||||
        {
 | 
			
		||||
            if (candidate == item)
 | 
			
		||||
            if (Equals(candidate, item))
 | 
			
		||||
            {
 | 
			
		||||
                return index;
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
}
 | 
			
		||||
@@ -14,9 +14,12 @@ internal sealed class ListPrompt<T>
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
        {
 | 
			
		||||
@@ -38,7 +41,7 @@ internal sealed class ListPrompt<T>
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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))
 | 
			
		||||
@@ -62,7 +65,7 @@ internal sealed class ListPrompt<T>
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
 | 
			
		||||
                if (state.Update(key) || result == ListPromptInputResult.Refresh)
 | 
			
		||||
                {
 | 
			
		||||
                    hook.Refresh();
 | 
			
		||||
                }
 | 
			
		||||
@@ -110,6 +113,8 @@ internal sealed class ListPrompt<T>
 | 
			
		||||
            _console,
 | 
			
		||||
            scrollable, cursorIndex,
 | 
			
		||||
            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 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)[/]";
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -94,7 +94,7 @@ public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrat
 | 
			
		||||
    {
 | 
			
		||||
        // Create the list prompt
 | 
			
		||||
        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)
 | 
			
		||||
        {
 | 
			
		||||
@@ -222,7 +222,8 @@ public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrat
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <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 highlightStyle = HighlightStyle ?? Color.Blue;
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,16 @@ 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 text that will be displayed when no search text has been entered.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string? SearchPlaceholderText { 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.
 | 
			
		||||
@@ -53,6 +63,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 search is enabled.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public bool SearchEnabled { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
@@ -84,7 +99,7 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
 | 
			
		||||
    {
 | 
			
		||||
        // Create the list prompt
 | 
			
		||||
        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 result.Items[result.Index].Data;
 | 
			
		||||
@@ -118,11 +133,20 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
 | 
			
		||||
            extra += 2;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Scrolling?
 | 
			
		||||
        if (totalItemCount > requestedPageSize)
 | 
			
		||||
        var scrollable = totalItemCount > requestedPageSize;
 | 
			
		||||
        if (SearchEnabled || scrollable)
 | 
			
		||||
        {
 | 
			
		||||
            // The scrolling instructions takes up two rows
 | 
			
		||||
            extra += 2;
 | 
			
		||||
            extra += 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (SearchEnabled)
 | 
			
		||||
        {
 | 
			
		||||
            extra += 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (scrollable)
 | 
			
		||||
        {
 | 
			
		||||
            extra += 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (requestedPageSize > console.Profile.Height - extra)
 | 
			
		||||
@@ -134,11 +158,13 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <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 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,15 +195,31 @@ public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
 | 
			
		||||
                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));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
        {
 | 
			
		||||
            // (Move up and down to reveal more choices)
 | 
			
		||||
            list.Add(Text.Empty);
 | 
			
		||||
            list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -182,6 +182,61 @@ public static class SelectionPromptExtensions
 | 
			
		||||
        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>
 | 
			
		||||
    /// Sets the highlight style of the selected choice.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user