Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/Spectre.Console/Prompts/List/IListPromptStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ 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, bool skipUnselectableItems, string searchText);
IEnumerable<(int Index, ListPromptItem<T> Node)> items, string searchText);
}
12 changes: 6 additions & 6 deletions src/Spectre.Console/Prompts/List/ListPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public async Task<ListPromptState<T>> Show(
SelectionMode selectionMode,
bool skipUnselectableItems,
bool searchEnabled,
bool filterOnSearch,
int requestedPageSize,
bool wrapAround,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -47,7 +48,7 @@ public async Task<ListPromptState<T>> Show(
throw new InvalidOperationException("Cannot show an empty selection prompt. Please call the AddChoice() method to configure the prompt.");
}

var state = new ListPromptState<T>(nodes, converter, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled);
var state = new ListPromptState<T>(nodes, converter, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize), wrapAround, selectionMode, skipUnselectableItems, searchEnabled, filterOnSearch);
var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));

using (new RenderHookScope(_console, hook))
Expand Down Expand Up @@ -90,14 +91,14 @@ private IRenderable BuildRenderable(ListPromptState<T> state)
var middleOfList = pageSize / 2;

var skip = 0;
var take = state.ItemCount;
var take = state.VisibleItems.Count;
var cursorIndex = state.Index;

var scrollable = state.ItemCount > pageSize;
var scrollable = state.VisibleItems.Count > pageSize;
if (scrollable)
{
skip = Math.Max(0, state.Index - middleOfList);
take = Math.Min(pageSize, state.ItemCount - skip);
take = Math.Min(pageSize, state.VisibleItems.Count - skip);

if (take < pageSize)
{
Expand All @@ -118,9 +119,8 @@ private IRenderable BuildRenderable(ListPromptState<T> state)
return _strategy.Render(
_console,
scrollable, cursorIndex,
state.Items.Skip(skip).Take(take)
state.VisibleItems.Skip(skip).Take(take)
.Select((node, index) => (index, node)),
state.SkipUnselectableItems,
state.SearchText);
}
}
265 changes: 139 additions & 126 deletions src/Spectre.Console/Prompts/List/ListPromptState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,178 +3,191 @@ namespace Spectre.Console;
internal sealed class ListPromptState<T>
where T : notnull
{
private readonly Func<T, string> _converter;

public int Index { get; private set; }
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 int PageSize { get; }
public string SearchText { get; private set; }
public List<ListPromptItem<T>> VisibleItems { get; private set; }
public int Index => _selectableItems.Count == 0 ? 0 : _selectableItems[_selectableIndex].Index;
public ListPromptItem<T>? Current => _selectableItems.Count == 0 ? null : _selectableItems[_selectableIndex].Item;

private readonly Func<T, string> _converter;
private readonly bool _skipUnselectableItems;
private readonly bool _filterOnSearch;
private readonly bool _wrapAround;
private readonly SelectionMode _mode;
private readonly bool _searchEnabled;
private List<SelectableItem> _selectableItems;
private int _selectableIndex;

public ListPromptState(
IReadOnlyList<ListPromptItem<T>> items,
Func<T, string> converter,
int pageSize, bool wrapAround,
int pageSize,
bool wrapAround,
SelectionMode mode,
bool skipUnselectableItems,
bool searchEnabled)
bool searchEnabled,
bool filterOnSearch)
{
_converter = converter ?? throw new ArgumentNullException(nameof(converter));
Items = items;
PageSize = pageSize;
WrapAround = wrapAround;
Mode = mode;
SkipUnselectableItems = skipUnselectableItems;
SearchEnabled = searchEnabled;
SearchText = string.Empty;
_converter = converter ?? throw new ArgumentNullException(nameof(converter));
_skipUnselectableItems = skipUnselectableItems;
_wrapAround = wrapAround;
_mode = mode;
_searchEnabled = searchEnabled;
_filterOnSearch = filterOnSearch;

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;
}
SearchText = string.Empty;
VisibleItems = Items.ToList();
_selectableItems = GetSelectableItems();
_selectableIndex = 0;
}

public bool Update(ConsoleKeyInfo keyInfo)
{
var index = Index;
if (SkipUnselectableItems && Mode == SelectionMode.Leaf)
if (_searchEnabled)
{
Debug.Assert(_leafIndexes != null, nameof(_leafIndexes) + " != null");
var currentLeafIndex = _leafIndexes.IndexOf(index);
switch (keyInfo.Key)
if (!char.IsControl(keyInfo.KeyChar))
{
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)
SearchText += keyInfo.KeyChar;
if (_filterOnSearch)
{
VisibleItems = FilterItemsBySearch();
_selectableItems = GetSelectableItems();
_selectableIndex = 0;
}
else
{
var item = _selectableItems
.FirstOrDefault(x => MatchesSearch(x.Item));
if (item != null)
{
index = _leafIndexes.FirstOrDefault();
_selectableIndex = _selectableItems.IndexOf(item);
}
}

break;

case ConsoleKey.Home:
index = _leafIndexes.FirstOrDefault();
break;

case ConsoleKey.End:
index = _leafIndexes.LastOrDefault();
break;
return true;
}

case ConsoleKey.PageUp:
index = Math.Max(currentLeafIndex - PageSize, 0);
if (index < _leafIndexes.Count)
if (keyInfo.Key == ConsoleKey.Backspace)
{
if (SearchText.Length > 0)
{
SearchText = SearchText[..^1];
if (_filterOnSearch)
{
index = _leafIndexes[index];
VisibleItems = FilterItemsBySearch();
_selectableItems = GetSelectableItems();
_selectableIndex = 0;
}

break;

case ConsoleKey.PageDown:
index = Math.Min(currentLeafIndex + PageSize, _leafIndexes.Count - 1);
if (index < _leafIndexes.Count)
else
{
index = _leafIndexes[index];
var item = _selectableItems
.FirstOrDefault(x => MatchesSearch(x.Item));
if (item != null)
{
_selectableIndex = _selectableItems.IndexOf(item);
}
}

break;
return true;
}
}
}
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)
switch (keyInfo.Key)
{
// If is text input, append to search filter
if (!char.IsControl(keyInfo.KeyChar))
{
search = SearchText + keyInfo.KeyChar;
case ConsoleKey.UpArrow:
if (_selectableIndex > 0)
{
_selectableIndex--;
}
else if (_wrapAround)
{
_selectableIndex = _selectableItems.Count - 1;
}

var item = Items.FirstOrDefault(x =>
_converter.Invoke(x.Data).Contains(search, StringComparison.OrdinalIgnoreCase)
&& (!x.IsGroup || Mode != SelectionMode.Leaf));
return true;

if (item != null)
case ConsoleKey.DownArrow:
if (_selectableIndex < _selectableItems.Count - 1)
{
index = Items.IndexOf(item);
_selectableIndex++;
}
else if (_wrapAround)
{
_selectableIndex = 0;
}
}

if (keyInfo.Key == ConsoleKey.Backspace)
{
if (search.Length > 0)
return true;

case ConsoleKey.Home:
_selectableIndex = 0;
return true;

case ConsoleKey.End:
_selectableIndex = _selectableItems.Count - 1;
return true;

case ConsoleKey.PageUp:
var pageUpIndex = Index - PageSize;
if (_wrapAround)
{
search = search.Substring(0, search.Length - 1);
pageUpIndex = (pageUpIndex + VisibleItems.Count) % VisibleItems.Count;
}
else
{
pageUpIndex = Math.Max(pageUpIndex, 0);
}

var item = Items.FirstOrDefault(x =>
_converter.Invoke(x.Data).Contains(search, StringComparison.OrdinalIgnoreCase) &&
(!x.IsGroup || Mode != SelectionMode.Leaf));
_selectableIndex = _selectableItems.IndexOf(_selectableItems.First(x => x.Index >= pageUpIndex));
return true;

if (item != null)
case ConsoleKey.PageDown:
var pageDownIndex = Index + PageSize;
if (_wrapAround)
{
index = Items.IndexOf(item);
pageDownIndex %= VisibleItems.Count;
}
}
else
{
pageDownIndex = Math.Min(pageDownIndex, VisibleItems.Count - 1);
}

_selectableIndex = _selectableItems.IndexOf(_selectableItems.First(x => x.Index >= pageDownIndex));
return true;
}

index = WrapAround
? (ItemCount + (index % ItemCount)) % ItemCount
: index.Clamp(0, ItemCount - 1);
return false;
}

private List<SelectableItem> GetSelectableItems()
{
var selectableItems = VisibleItems
.Select((item, filteredIndex) => new SelectableItem(item, filteredIndex));

if (index != Index || SearchText != search)
if (_skipUnselectableItems && _mode == SelectionMode.Leaf)
{
Index = index;
SearchText = search;
return true;
selectableItems = selectableItems.Where(x => !x.Item.IsGroup);
}

return false;
return selectableItems.ToList();
}

private List<ListPromptItem<T>> FilterItemsBySearch()
{
return Items
.Where(x => MatchesSearch(x) || x.Children.Any(MatchesSearch))
.ToList();
}

private bool MatchesSearch(ListPromptItem<T> item) =>
_converter.Invoke(item.Data).Contains(SearchText, StringComparison.OrdinalIgnoreCase);

private class SelectableItem(ListPromptItem<T> item, int index)
{
public ListPromptItem<T> Item { get; } = item;
public int Index { get; } = index;
}
}
}
Loading