From fa6604abe65aca67fae5afe69aeb2fb3690d9ca8 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 6 Mar 2024 00:04:44 +0000 Subject: [PATCH] Update Prompts --- .../Extensions/StringExtensions.cs | 104 ++++++++++- .../Extensions/EnumerableExtensions.cs | 7 +- .../Prompts/List/IListPromptStrategy.cs | 6 +- .../Prompts/List/ListPrompt.cs | 30 +++- .../Prompts/List/ListPromptConstants.cs | 1 + .../Prompts/List/ListPromptState.cs | 163 ++++++++++++++++-- .../Prompts/MultiSelectionPrompt.cs | 44 +++-- .../Prompts/SelectionPrompt.cs | 73 ++++++-- .../Prompts/SelectionPromptExtensions.cs | 55 ++++++ .../Spectre.Console/Widgets/Paragraph.cs | 1 - 10 files changed, 417 insertions(+), 67 deletions(-) diff --git a/src/Spectre.Console.Rx/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console.Rx/Spectre.Console/Extensions/StringExtensions.cs index 0c18188..7bd36e9 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Extensions/StringExtensions.cs @@ -84,19 +84,106 @@ public static string Mask(this string value, char? mask) throw new ArgumentNullException(nameof(value)); } - var output = string.Empty; - if (mask is null) { - return output; + return string.Empty; + } + + return new string(mask.Value, value.Length); + } + + /// + /// Highlights the first text match in provided value. + /// + /// Input value. + /// Text to search for. + /// The style to apply to the matched text. + /// Markup of input with the first matched text highlighted. + 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(); } internal static string CapitalizeFirstLetter(this string? text, CultureInfo? culture = null) @@ -202,6 +289,11 @@ internal static string Repeat(this string text, int count) return string.Concat(Enumerable.Repeat(text, count)); } +#if NETSTANDARD2_0 + internal static bool Contains(this string target, string value, StringComparison comparisonType) => + target.IndexOf(value, comparisonType) != -1; +#endif + internal static string ReplaceExact(this string text, string oldValue, string? newValue) => #if NETSTANDARD2_0 text.Replace(oldValue, newValue); diff --git a/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs b/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs index a3858c0..cc92041 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs @@ -31,12 +31,11 @@ public static IEnumerable Repeat(this IEnumerable source, int count) } public static int IndexOf(this IEnumerable source, T item) - where T : class { var index = 0; foreach (var candidate in source) { - if (candidate == item) + if (Equals(candidate, item)) { return index; } @@ -89,9 +88,9 @@ public static void ForEach(this IEnumerable source, Action action) throw new ArgumentNullException(nameof(source)); } - return DoEnumeration(); + return EnumerateValues(); - IEnumerable<(int Index, bool First, bool Last, T Item)> DoEnumeration() + IEnumerable<(int Index, bool First, bool Last, T Item)> EnumerateValues() { var first = true; var last = !source.MoveNext(); diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/IListPromptStrategy.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/IListPromptStrategy.cs index c2c04aa..7fab8f7 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/IListPromptStrategy.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/IListPromptStrategy.cs @@ -34,6 +34,8 @@ internal interface IListPromptStrategy /// Whether or not the list is scrollable. /// The cursor index. /// The visible items. + /// A value indicating whether or not the prompt should skip unselectable items. + /// The search text. /// A representing the items. - public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items); -} \ No newline at end of file + public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText); +} diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPrompt.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPrompt.cs index 2575c0c..a33bbfa 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPrompt.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPrompt.cs @@ -3,17 +3,26 @@ namespace Spectre.Console.Rx; -internal sealed class ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) +internal sealed class ListPrompt where T : notnull { - private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); - private readonly IListPromptStrategy _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + private readonly IAnsiConsole _console; + private readonly IListPromptStrategy _strategy; + + public ListPrompt(IAnsiConsole console, IListPromptStrategy strategy) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + } public async Task> Show( ListPromptTree 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) { @@ -35,7 +44,7 @@ public async Task> Show( } var nodes = tree.Traverse().ToList(); - var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround); + var state = new ListPromptState(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled); var hook = new ListPromptRenderHook(_console, () => BuildRenderable(state)); using (new RenderHookScope(_console, hook)) @@ -45,6 +54,7 @@ public async Task> Show( while (true) { + cancellationToken.ThrowIfCancellationRequested(); var rawKey = await _console.Input.ReadKeyAsync(true, cancellationToken).ConfigureAwait(false); if (rawKey == null) { @@ -58,7 +68,7 @@ public async Task> Show( break; } - if (state.Update(key.Key) || result == ListPromptInputResult.Refresh) + if (state.Update(key) || result == ListPromptInputResult.Refresh) { hook.Refresh(); } @@ -107,6 +117,8 @@ private IRenderable BuildRenderable(ListPromptState state) scrollable, cursorIndex, state.Items.Skip(skip).Take(take) - .Select((node, index) => (index, node))); + .Select((node, index) => (index, node)), + state.SkipUnselectableItems, + state.SearchText); } } diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptConstants.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptConstants.cs index c9b4dec..679119f 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptConstants.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptConstants.cs @@ -11,4 +11,5 @@ internal static class ListPromptConstants public const string GroupSelectedCheckbox = "[[[grey]X[/]]]"; public const string InstructionsMarkup = "[grey](Press to select, to accept)[/]"; public const string MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]"; + public const string SearchPlaceholderMarkup = "[grey](Type to search)[/]"; } diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptState.cs index a8487f9..7a2b654 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/List/ListPromptState.cs @@ -1,42 +1,175 @@ // Copyright (c) Chris Pulman. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Collections.Generic; + namespace Spectre.Console.Rx; -internal sealed class ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround) +internal sealed class ListPromptState where T : notnull { + private readonly IReadOnlyList? _leafIndexes; + + public ListPromptState(IReadOnlyList> items, int pageSize, bool wrapAround, SelectionMode mode, bool skipUnselectableItems, bool searchEnabled) + { + 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[0]; + } + else + { + Index = 0; + } + } + public int Index { get; private set; } public int ItemCount => Items.Count; - public int PageSize { get; } = pageSize; + public int PageSize { get; } + + public bool WrapAround { get; } + + public SelectionMode Mode { get; } - public bool WrapAround { get; } = wrapAround; + public bool SkipUnselectableItems { get; } - public IReadOnlyList> Items { get; } = items; + public bool SearchEnabled { get; } + + public IReadOnlyList> Items { get; } public ListPromptItem Current => Items[Index]; - public bool Update(ConsoleKey key) + public string SearchText { get; private set; } + + public bool Update(ConsoleKeyInfo keyInfo) { - var index = key switch + var index = Index; + if (SkipUnselectableItems && Mode == SelectionMode.Leaf) + { + 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[_leafIndexes.Count - 1]; + } + + break; + + case ConsoleKey.DownArrow: + if (currentLeafIndex < _leafIndexes.Count - 1) + { + index = _leafIndexes[currentLeafIndex + 1]; + } + else if (WrapAround) + { + index = _leafIndexes[0]; + } + + break; + + case ConsoleKey.Home: + index = _leafIndexes[0]; + break; + + case ConsoleKey.End: + index = _leafIndexes[_leafIndexes.Count - 1]; + 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 { - ConsoleKey.UpArrow => Index - 1, - ConsoleKey.DownArrow => Index + 1, - ConsoleKey.Home => 0, - ConsoleKey.End => ItemCount - 1, - ConsoleKey.PageUp => Index - PageSize, - ConsoleKey.PageDown => Index + PageSize, - _ => Index, - }; + 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; } diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/MultiSelectionPrompt.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/MultiSelectionPrompt.cs index edd21bf..0c1eb5a 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/MultiSelectionPrompt.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/MultiSelectionPrompt.cs @@ -7,12 +7,21 @@ namespace Spectre.Console.Rx; /// Represents a multi selection list prompt. /// /// The prompt result type. -/// -/// Initializes a new instance of the class. -/// -public sealed class MultiSelectionPrompt(IEqualityComparer? comparer = null) : IPrompt>, IListPromptStrategy +public sealed class MultiSelectionPrompt : IPrompt>, IListPromptStrategy where T : notnull { + /// + /// Initializes a new instance of the class. + /// + /// + /// The implementation to use when comparing items, + /// or null to use the default for the type of the item. + /// + public MultiSelectionPrompt(IEqualityComparer? comparer = null) + { + Tree = new ListPromptTree(comparer ?? EqualityComparer.Default); + } + /// /// Gets or sets the title. /// @@ -63,7 +72,7 @@ public sealed class MultiSelectionPrompt(IEqualityComparer? comparer = nul /// public SelectionMode Mode { get; set; } = SelectionMode.Leaf; - internal ListPromptTree Tree { get; } = new ListPromptTree(comparer ?? EqualityComparer.Default); + internal ListPromptTree Tree { get; } /// /// Adds a choice. @@ -85,7 +94,7 @@ public async Task> ShowAsync(IAnsiConsole console, CancellationToken can { // Create the list prompt var prompt = new ListPrompt(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) { @@ -108,7 +117,12 @@ public async Task> ShowAsync(IAnsiConsole console, CancellationToken can /// The parent items, or an empty list, if the given item has no parents. public IEnumerable GetParents(T item) { - var promptItem = Tree.Find(item) ?? throw new ArgumentOutOfRangeException(nameof(item), "Item not found in tree."); + var promptItem = Tree.Find(item); + if (promptItem == null) + { + throw new ArgumentOutOfRangeException(nameof(item), "Item not found in tree."); + } + var parents = new List>(); while (promptItem.Parent != null) { @@ -205,7 +219,7 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText) { var list = new List(); var highlightStyle = HighlightStyle ?? Color.Blue; @@ -223,22 +237,22 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, grid.AddEmptyRow(); } - foreach (var (index, node) in items) + foreach (var item in items) { - var current = index == cursorIndex; + var current = item.Index == cursorIndex; var style = current ? highlightStyle : Style.Plain; - var indent = new string(' ', node.Depth * 2); - var prompt = index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length); + var indent = new string(' ', item.Node.Depth * 2); + var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length); - var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(node.Data) ?? node.Data.ToString() ?? "?"; + var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?"; if (current) { text = text.RemoveMarkup().EscapeMarkup(); } - var checkbox = node.IsSelected - ? (node.IsGroup && Mode == SelectionMode.Leaf + var checkbox = item.Node.IsSelected + ? (item.Node.IsGroup && Mode == SelectionMode.Leaf ? ListPromptConstants.GroupSelectedCheckbox : ListPromptConstants.SelectedCheckbox) : ListPromptConstants.Checkbox; diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPrompt.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPrompt.cs index 6a60fcc..46cb925 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPrompt.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPrompt.cs @@ -4,9 +4,11 @@ namespace Spectre.Console.Rx; /// -/// Represents a single list prompt. +/// SelectionPrompt. /// -/// The prompt result type. +/// The type. +/// +/// public sealed class SelectionPrompt : IPrompt, IListPromptStrategy where T : notnull { @@ -44,6 +46,16 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public Style? DisabledStyle { get; set; } + /// + /// Gets or sets the style of highlighted search matches. + /// + public Style? SearchHighlightStyle { get; set; } + + /// + /// Gets or sets the text that will be displayed when no search text has been entered. + /// + public string? SearchPlaceholderText { get; set; } + /// /// Gets or sets the converter to get the display string for a choice. By default /// the corresponding is used. @@ -61,6 +73,11 @@ public sealed class SelectionPrompt : IPrompt, IListPromptStrategy /// public SelectionMode Mode { get; set; } = SelectionMode.Leaf; + /// + /// Gets or sets a value indicating whether or not search is enabled. + /// + public bool SearchEnabled { get; set; } + /// /// Adds a choice. /// @@ -81,7 +98,7 @@ public async Task ShowAsync(IAnsiConsole console, CancellationToken cancellat { // Create the list prompt var prompt = new ListPrompt(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; @@ -115,11 +132,20 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem extra += 2; } - // Scrolling? - if (totalItemCount > requestedPageSize) + var scrollable = totalItemCount > requestedPageSize; + if (SearchEnabled || scrollable) { - // The scrolling instructions takes up two rows - extra += 2; + extra++; + } + + if (SearchEnabled) + { + extra++; + } + + if (scrollable) + { + extra++; } if (requestedPageSize > console.Profile.Height - extra) @@ -131,11 +157,12 @@ int IListPromptStrategy.CalculatePageSize(IAnsiConsole console, int totalItem } /// - IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items) + IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem Node)> items, bool skipUnselectableItems, string searchText) { var list = new List(); 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) { @@ -150,31 +177,47 @@ IRenderable IListPromptStrategy.Render(IAnsiConsole console, bool scrollable, grid.AddEmptyRow(); } - foreach (var (index, node) in items) + foreach (var item in items) { - var current = index == cursorIndex; - var prompt = index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length); - var style = node.IsGroup && Mode == SelectionMode.Leaf + var current = item.Index == cursorIndex; + var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length); + var style = item.Node.IsGroup && Mode == SelectionMode.Leaf ? disabledStyle : current ? highlightStyle : Style.Plain; - var indent = new string(' ', node.Depth * 2); + var indent = new string(' ', item.Node.Depth * 2); - var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(node.Data) ?? node.Data.ToString() ?? "?"; + var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?"; if (current) { 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)); } diff --git a/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPromptExtensions.cs b/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPromptExtensions.cs index 482f17c..6210493 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPromptExtensions.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Prompts/SelectionPromptExtensions.cs @@ -205,6 +205,61 @@ public static SelectionPrompt WrapAround(this SelectionPrompt obj, bool return obj; } + /// + /// Enables search for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt EnableSearch(this SelectionPrompt obj) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchEnabled = true; + return obj; + } + + /// + /// Disables search for the prompt. + /// + /// The prompt result type. + /// The prompt. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt DisableSearch(this SelectionPrompt obj) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchEnabled = false; + return obj; + } + + /// + /// Sets the text that will be displayed when no search text has been entered. + /// + /// The prompt result type. + /// The prompt. + /// The text to display. + /// The same instance so that multiple calls can be chained. + public static SelectionPrompt SearchPlaceholderText(this SelectionPrompt obj, string? text) + where T : notnull + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SearchPlaceholderText = text; + return obj; + } + /// /// Sets the highlight style of the selected choice. /// diff --git a/src/Spectre.Console.Rx/Spectre.Console/Widgets/Paragraph.cs b/src/Spectre.Console.Rx/Spectre.Console/Widgets/Paragraph.cs index a57a17c..4e46813 100644 --- a/src/Spectre.Console.Rx/Spectre.Console/Widgets/Paragraph.cs +++ b/src/Spectre.Console.Rx/Spectre.Console/Widgets/Paragraph.cs @@ -7,7 +7,6 @@ namespace Spectre.Console.Rx; /// A paragraph of text where different parts /// of the paragraph can have individual styling. /// -[DebuggerDisplay("{_text,nq}")] public sealed class Paragraph : Renderable, IHasJustification, IOverflowable { private readonly List _lines;