Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a prompt that allows a selection of a row in a table #888

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
38 changes: 38 additions & 0 deletions examples/Console/Prompt/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ namespace Prompt
{
public static class Program
{
private class City
{
public string Name { get; set; }
public string Country { get; set; }
public float Area { get; set; }
public int Population { get; set; }
}

public static void Main(string[] args)
{
// Check if we can accept key strokes
Expand All @@ -27,6 +35,7 @@ public static void Main(string[] args)
var fruit = AskFruit();

WriteDivider("Choices");
var city = AskCity();
var sport = AskSport();

WriteDivider("Integers");
Expand All @@ -52,6 +61,7 @@ public static void Main(string[] args)
.BorderColor(Color.Grey)
.AddRow("[grey]Name[/]", name)
.AddRow("[grey]Favorite fruit[/]", fruit)
.AddRow("[grey]Favorite city[/]", city.Name)
.AddRow("[grey]Favorite sport[/]", sport)
.AddRow("[grey]Age[/]", age.ToString())
.AddRow("[grey]Password[/]", password)
Expand Down Expand Up @@ -120,6 +130,34 @@ public static string AskFruit()
return fruit;
}

private static City AskCity()
{
var city = AnsiConsole.Prompt(
new TablePrompt<City>()
.Title("What is your [green]favorite city[/]?")
.AddColumns("Name", "Country", "Area", "Population")
.UseConverter((item, index) =>
{
return index switch
{
0 => item.Name,
1 => item.Country,
2 => $"{item.Area} km2",
3 => item.Population.ToString("N0"),
_ => string.Empty,
};
})
.AddChoices(
new City { Name = "Paris", Country = "France", Area = 105.4F, Population = 2175601 },
new City { Name = "London", Country = "England", Area = 1572F, Population = 8961989 },
new City { Name = "Los Angeles", Country = "United States", Area = 1299.01F, Population = 3898747 },
new City { Name = "Shanghai", Country = "China", Area = 6341F, Population = 24870895 }
));

AnsiConsole.MarkupLine("Your selected: [yellow]{0}[/]", city.Name);
return city;
}

public static string AskSport()
{
return AnsiConsole.Prompt(
Expand Down
160 changes: 160 additions & 0 deletions src/Spectre.Console/Prompts/TablePrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
namespace Spectre.Console;

/// <summary>
/// Represents a prompt in table format.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
public sealed class TablePrompt<T> : IPrompt<T>, IListPromptStrategy<T>
where T : notnull
{
private readonly ListPromptTree<T> _tree;
private readonly List<string> _columns;

/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }

/// <summary>
/// Gets or sets the page size.
/// Defaults to <c>10</c>.
/// </summary>
public int PageSize { get; set; } = 10;

/// <summary>
/// Gets or sets a value indicating whether the selection should wrap around when reaching the edge.
/// Defaults to <c>false</c>.
/// </summary>
public bool WrapAround { get; set; } = false;

/// <summary>
/// Gets or sets the highlight style of the selected choice.
/// </summary>
public Style? HighlightStyle { get; set; }

/// <summary>
/// Gets or sets the converter to get the display string for a choice and column.
/// By default the corresponding <see cref="TypeConverter"/> is used.
/// </summary>
public Func<T, int, string>? Converter { get; set; }

/// <summary>
/// Gets or sets the selection mode.
/// Defaults to <see cref="SelectionMode.Leaf"/>.
/// </summary>
public SelectionMode Mode { get; set; } = SelectionMode.Leaf;

/// <summary>
/// Initializes a new instance of the <see cref="TablePrompt{T}"/> class.
/// </summary>
public TablePrompt()
{
_tree = new ListPromptTree<T>(EqualityComparer<T>.Default);
_columns = new List<string>();
}

/// <summary>
/// Adds a column to the table.
/// </summary>
/// <param name="column">The column to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public TablePrompt<T> AddColumn(string column)
{
_columns.Add(column);
return this;
}

/// <summary>
/// Adds a choice.
/// </summary>
/// <param name="item">The item to add.</param>
/// <returns>A <see cref="ISelectionItem{T}"/> so that multiple calls can be chained.</returns>
public ISelectionItem<T> AddChoice(T item)
{
var node = new ListPromptItem<T>(item);
_tree.Add(node);
return node;
}

/// <inheritdoc/>
public T Show(IAnsiConsole console)
{
return ShowAsync(console, CancellationToken.None).GetAwaiter().GetResult();
}

/// <inheritdoc/>
public async Task<T> ShowAsync(IAnsiConsole console, CancellationToken cancellationToken)
{
// Create the list prompt
var prompt = new ListPrompt<T>(console, this);
var result = await prompt.Show(_tree, Mode, false, false, PageSize, WrapAround, cancellationToken).ConfigureAwait(false);

// Return the selected item
return result.Items[result.Index].Data;
}

/// <inheritdoc/>
ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state)
{
if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar || key.Key == ConsoleKey.Packet)
{
// Selecting a non leaf in "leaf mode" is not allowed
if (state.Current.IsGroup && Mode == SelectionMode.Leaf)
{
return ListPromptInputResult.None;
}

return ListPromptInputResult.Submit;
}

return ListPromptInputResult.None;
}

/// <inheritdoc/>
int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize)
{
// Display the entire table
return totalItemCount;
}

/// <inheritdoc/>
IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex,
IEnumerable<(int Index, ListPromptItem<T> Node)> items, bool skipUnselectableItems, string searchText)
{
var highlightStyle = HighlightStyle ?? Color.Blue;
var table = new Table();

if (Title != null)
{
table.Title = new TableTitle(Title);
}

foreach (var column in _columns)
{
table.AddColumn(column);
}

foreach (var item in items)
{
var current = item.Index == cursorIndex;
var style = current ? highlightStyle : Style.Plain;

var columns = new List<IRenderable>();

for (var columnIndex = 0; columnIndex < _columns.Count; columnIndex++)
{
var text = Converter?.Invoke(item.Node.Data, columnIndex) ?? TypeConverterHelper.ConvertToString(item.Node.Data) ?? item.Node.Data.ToString() ?? "?";
if (current)
{
text = text.RemoveMarkup().EscapeMarkup();
}

columns.Add(new Markup(text, style));
}

table.AddRow(columns);
}

return table;
}
}
138 changes: 138 additions & 0 deletions src/Spectre.Console/Prompts/TablePromptExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace Spectre.Console;

/// <summary>
/// Contains extension methods for <see cref="TablePrompt{T}"/>.
/// </summary>
public static class TablePromptExtensions
{
/// <summary>
/// Adds multiple columns to the table.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="columns">The columns to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> AddColumns<T>(this TablePrompt<T> obj, params string[] columns)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

if (columns is null)
{
throw new ArgumentNullException(nameof(columns));
}

foreach (var column in columns)
{
obj.AddColumn(column);
}

return obj;
}

/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> AddChoices<T>(this TablePrompt<T> obj, params T[] choices)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

foreach (var choice in choices)
{
obj.AddChoice(choice);
}

return obj;
}

/// <summary>
/// Adds multiple choices.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="choices">The choices to add.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> AddChoices<T>(this TablePrompt<T> obj, IEnumerable<T> choices)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

foreach (var choice in choices)
{
obj.AddChoice(choice);
}

return obj;
}

/// <summary>
/// Sets the title.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="title">The title markup text.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> Title<T>(this TablePrompt<T> obj, string? title)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

obj.Title = title;
return obj;
}

/// <summary>
/// Sets the highlight style of the selected choice.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="highlightStyle">The highlight style of the selected choice.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> HighlightStyle<T>(this TablePrompt<T> obj, Style highlightStyle)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

obj.HighlightStyle = highlightStyle;
return obj;
}

/// <summary>
/// Sets the function to create a display string for a given choice and column.
/// </summary>
/// <typeparam name="T">The prompt type.</typeparam>
/// <param name="obj">The table prompt.</param>
/// <param name="displaySelector">The function to get a display string for a given choice and column.</param>
/// <returns>The same instance so that multiple calls can be chained.</returns>
public static TablePrompt<T> UseConverter<T>(this TablePrompt<T> obj, Func<T, int, string>? displaySelector)
where T : notnull
{
if (obj is null)
{
throw new ArgumentNullException(nameof(obj));
}

obj.Converter = displaySelector;
return obj;
}
}
18 changes: 18 additions & 0 deletions test/Spectre.Console.Tests/Unit/Prompts/TablePromptTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Spectre.Console.Tests.Unit;

public sealed class TablePromptTests
{
[Fact]
public void Should_Throw_If_Columns_Are_Null()
{
// Given
var prompt = new TablePrompt<string>();

// When
var result = Record.Exception(() => prompt.AddColumns(null));

// Then
result.ShouldBeOfType<ArgumentNullException>()
.ParamName.ShouldBe("columns");
}
}