Add autocomplete for text prompt

Closes #166
This commit is contained in:
Patrik Svensson 2020-12-21 03:21:02 +01:00 committed by Patrik Svensson
parent e280b82679
commit 1cf30f62fc
41 changed files with 218 additions and 104 deletions

View File

@ -0,0 +1 @@
Favorite fruit? [Banana/Bandana/Orange]: Band    Bandana

View File

@ -0,0 +1 @@
Favorite fruit? [Banana/Orange] (Banana): Banana

View File

@ -0,0 +1 @@
Favorite fruit? [Apple/Banana/Orange]: Apple     Banana

View File

@ -23,7 +23,11 @@ namespace Spectre.Console.Tests
{ {
PushCharacter(character); PushCharacter(character);
} }
}
public void PushTextWithEnter(string input)
{
PushText(input);
PushKey(ConsoleKey.Enter); PushKey(ConsoleKey.Enter);
} }

View File

@ -1,7 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Shouldly;
using Spectre.Console.Rendering; using Spectre.Console.Rendering;
using VerifyXunit; using VerifyXunit;
using Xunit; using Xunit;

View File

@ -13,8 +13,8 @@ namespace Spectre.Console.Tests.Unit
{ {
// Given // Given
var console = new PlainConsole(); var console = new PlainConsole();
console.Input.PushText("ninety-nine"); console.Input.PushTextWithEnter("ninety-nine");
console.Input.PushText("99"); console.Input.PushTextWithEnter("99");
// When // When
console.Prompt(new TextPrompt<int>("Age?")); console.Prompt(new TextPrompt<int>("Age?"));
@ -46,8 +46,8 @@ namespace Spectre.Console.Tests.Unit
{ {
// Given // Given
var console = new PlainConsole(); var console = new PlainConsole();
console.Input.PushText("Apple"); console.Input.PushTextWithEnter("Apple");
console.Input.PushText("Banana"); console.Input.PushTextWithEnter("Banana");
// When // When
console.Prompt( console.Prompt(
@ -65,7 +65,7 @@ namespace Spectre.Console.Tests.Unit
{ {
// Given // Given
var console = new PlainConsole(); var console = new PlainConsole();
console.Input.PushText("Orange"); console.Input.PushTextWithEnter("Orange");
// When // When
console.Prompt( console.Prompt(
@ -78,15 +78,74 @@ namespace Spectre.Console.Tests.Unit
return Verifier.Verify(console.Output); return Verifier.Verify(console.Output);
} }
[Fact]
public Task Should_Auto_Complete_To_First_Choice_If_Pressing_Tab_On_Empty_String()
{
// Given
var console = new PlainConsole();
console.Input.PushKey(ConsoleKey.Tab);
console.Input.PushKey(ConsoleKey.Enter);
// When
console.Prompt(
new TextPrompt<string>("Favorite fruit?")
.AddChoice("Banana")
.AddChoice("Orange")
.DefaultValue("Banana"));
// Then
return Verifier.Verify(console.Output);
}
[Fact]
public Task Should_Auto_Complete_To_Best_Match()
{
// Given
var console = new PlainConsole();
console.Input.PushText("Band");
console.Input.PushKey(ConsoleKey.Tab);
console.Input.PushKey(ConsoleKey.Enter);
// When
console.Prompt(
new TextPrompt<string>("Favorite fruit?")
.AddChoice("Banana")
.AddChoice("Bandana")
.AddChoice("Orange"));
// Then
return Verifier.Verify(console.Output);
}
[Fact]
public Task Should_Auto_Complete_To_Next_Choice_When_Pressing_Tab_On_A_Match()
{
// Given
var console = new PlainConsole();
console.Input.PushText("Apple");
console.Input.PushKey(ConsoleKey.Tab);
console.Input.PushKey(ConsoleKey.Enter);
// When
console.Prompt(
new TextPrompt<string>("Favorite fruit?")
.AddChoice("Apple")
.AddChoice("Banana")
.AddChoice("Orange"));
// Then
return Verifier.Verify(console.Output);
}
[Fact] [Fact]
public Task Should_Return_Error_If_Custom_Validation_Fails() public Task Should_Return_Error_If_Custom_Validation_Fails()
{ {
// Given // Given
var console = new PlainConsole(); var console = new PlainConsole();
console.Input.PushText("22"); console.Input.PushTextWithEnter("22");
console.Input.PushText("102"); console.Input.PushTextWithEnter("102");
console.Input.PushText("ABC"); console.Input.PushTextWithEnter("ABC");
console.Input.PushText("99"); console.Input.PushTextWithEnter("99");
// When // When
console.Prompt( console.Prompt(

View File

@ -1,4 +1,7 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Spectre.Console namespace Spectre.Console
{ {
@ -7,7 +10,7 @@ namespace Spectre.Console
/// </summary> /// </summary>
public static partial class AnsiConsoleExtensions public static partial class AnsiConsoleExtensions
{ {
internal static string ReadLine(this IAnsiConsole console, Style? style, bool secret) internal static string ReadLine(this IAnsiConsole console, Style? style, bool secret, IEnumerable<string>? items = null)
{ {
if (console is null) if (console is null)
{ {
@ -15,35 +18,83 @@ namespace Spectre.Console
} }
style ??= Style.Plain; style ??= Style.Plain;
var text = string.Empty;
var autocomplete = new List<string>(items ?? Enumerable.Empty<string>());
var result = string.Empty;
while (true) while (true)
{ {
var key = console.Input.ReadKey(true); var key = console.Input.ReadKey(true);
if (key.Key == ConsoleKey.Enter) if (key.Key == ConsoleKey.Enter)
{ {
return result; return text;
}
if (key.Key == ConsoleKey.Tab && autocomplete.Count > 0)
{
var replace = AutoComplete(autocomplete, text);
if (!string.IsNullOrEmpty(replace))
{
// Render the suggestion
console.Write("\b \b".Repeat(text.Length), style);
console.Write(replace);
text = replace;
continue;
}
} }
if (key.Key == ConsoleKey.Backspace) if (key.Key == ConsoleKey.Backspace)
{ {
if (result.Length > 0) if (text.Length > 0)
{ {
result = result.Substring(0, result.Length - 1); text = text.Substring(0, text.Length - 1);
console.Write("\b \b"); console.Write("\b \b");
} }
continue; continue;
} }
result += key.KeyChar.ToString();
if (!char.IsControl(key.KeyChar)) if (!char.IsControl(key.KeyChar))
{ {
text += key.KeyChar.ToString();
console.Write(secret ? "*" : key.KeyChar.ToString(), style); console.Write(secret ? "*" : key.KeyChar.ToString(), style);
} }
} }
} }
private static string AutoComplete(List<string> autocomplete, string text)
{
var found = autocomplete.Find(i => i == text);
var replace = string.Empty;
if (found == null)
{
// Get the closest match
var next = autocomplete.Find(i => i.StartsWith(text, true, CultureInfo.InvariantCulture));
if (next != null)
{
replace = next;
}
else if (string.IsNullOrEmpty(text))
{
// Use the first item
replace = autocomplete[0];
}
}
else
{
// Get the next match
var index = autocomplete.IndexOf(found) + 1;
if (index >= autocomplete.Count)
{
index = 0;
}
replace = autocomplete[index];
}
return replace;
}
} }
} }

View File

@ -0,0 +1,55 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace Spectre.Console.Internal
{
internal static class TypeConverterHelper
{
public static string ConvertToString<T>(T input)
{
return GetTypeConverter<T>().ConvertToInvariantString(input);
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public static bool TryConvertFromString<T>(string input, [MaybeNull] out T result)
{
try
{
result = (T)GetTypeConverter<T>().ConvertFromInvariantString(input);
return true;
}
catch
{
result = default;
return false;
}
}
public static TypeConverter GetTypeConverter<T>()
{
var converter = TypeDescriptor.GetConverter(typeof(T));
if (converter != null)
{
return converter;
}
var attribute = typeof(T).GetCustomAttribute<TypeConverterAttribute>();
if (attribute != null)
{
var type = Type.GetType(attribute.ConverterTypeName, false, false);
if (type != null)
{
converter = Activator.CreateInstance(type) as TypeConverter;
if (converter != null)
{
return converter;
}
}
}
throw new InvalidOperationException("Could not find type converter");
}
}
}

View File

@ -2,16 +2,17 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net5.0;netstandard2.0</TargetFrameworks> <TargetFrameworks>net5.0;netstandard2.0</TargetFrameworks>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Remove="Figlet\Fonts\Standard.flf" /> <None Remove="Widgets\Figlet\Fonts\Standard.flf" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="..\stylecop.json" Link="Properties/stylecop.json" /> <AdditionalFiles Include="..\stylecop.json" Link="Properties/stylecop.json" />
<EmbeddedResource Include="Figlet\Fonts\Standard.flf" /> <EmbeddedResource Include="Widgets\Figlet\Fonts\Standard.flf" />
<None Include="../../resources/gfx/small-logo.png" Pack="true" PackagePath="\" Link="Properties/small-logo.png" /> <None Include="../../resources/gfx/small-logo.png" Pack="true" PackagePath="\" Link="Properties/small-logo.png" />
</ItemGroup> </ItemGroup>

View File

@ -10,6 +10,8 @@ namespace Spectre.Console
/// </summary> /// </summary>
public sealed class FigletFont public sealed class FigletFont
{ {
private const string StandardFont = "Spectre.Console/Widgets/Figlet/Fonts/Standard.flf";
private readonly Dictionary<int, FigletCharacter> _characters; private readonly Dictionary<int, FigletCharacter> _characters;
private static readonly Lazy<FigletFont> _standard; private static readonly Lazy<FigletFont> _standard;
@ -40,7 +42,8 @@ namespace Spectre.Console
static FigletFont() static FigletFont()
{ {
_standard = new Lazy<FigletFont>(() => Parse(ResourceReader.ReadManifestData("Spectre.Console/Figlet/Fonts/Standard.flf"))); _standard = new Lazy<FigletFont>(() => Parse(
ResourceReader.ReadManifestData(StandardFont)));
} }
internal FigletFont(IEnumerable<FigletCharacter> characters, FigletHeader header) internal FigletFont(IEnumerable<FigletCharacter> characters, FigletHeader header)

View File

@ -0,0 +1,12 @@
namespace Spectre.Console
{
internal sealed class DefaultPromptValue<T>
{
public T Value { get; }
public DefaultPromptValue(T value)
{
Value = value;
}
}
}

View File

@ -1,11 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Text; using System.Text;
using Spectre.Console.Internal;
namespace Spectre.Console namespace Spectre.Console
{ {
@ -68,27 +67,7 @@ namespace Spectre.Console
/// <summary> /// <summary>
/// Gets or sets the default value. /// Gets or sets the default value.
/// </summary> /// </summary>
internal DefaultValueContainer? DefaultValue { get; set; } internal DefaultPromptValue<T>? DefaultValue { get; set; }
/// <summary>
/// A nullable container for a default value.
/// </summary>
internal sealed class DefaultValueContainer
{
/// <summary>
/// Gets the default value.
/// </summary>
public T Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DefaultValueContainer"/> class.
/// </summary>
/// <param name="value">The default value.</param>
public DefaultValueContainer(T value)
{
Value = value;
}
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TextPrompt{T}"/> class. /// Initializes a new instance of the <see cref="TextPrompt{T}"/> class.
@ -116,19 +95,20 @@ namespace Spectre.Console
} }
var promptStyle = PromptStyle ?? Style.Plain; var promptStyle = PromptStyle ?? Style.Plain;
var choices = Choices.Select(choice => TypeConverterHelper.ConvertToString(choice));
WritePrompt(console); WritePrompt(console);
while (true) while (true)
{ {
var input = console.ReadLine(promptStyle, IsSecret); var input = console.ReadLine(promptStyle, IsSecret, choices);
// Nothing entered? // Nothing entered?
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
{ {
if (DefaultValue != null) if (DefaultValue != null)
{ {
console.Write(TextPrompt<T>.GetTypeConverter().ConvertToInvariantString(DefaultValue.Value), promptStyle); console.Write(TypeConverterHelper.ConvertToString(DefaultValue.Value), promptStyle);
console.WriteLine(); console.WriteLine();
return DefaultValue.Value; return DefaultValue.Value;
} }
@ -142,7 +122,7 @@ namespace Spectre.Console
console.WriteLine(); console.WriteLine();
// Try convert the value to the expected type. // Try convert the value to the expected type.
if (!TextPrompt<T>.TryConvert(input, out var result) || result == null) if (!TypeConverterHelper.TryConvertFromString<T>(input, out var result) || result == null)
{ {
console.MarkupLine(ValidationErrorMessage); console.MarkupLine(ValidationErrorMessage);
WritePrompt(console); WritePrompt(console);
@ -191,7 +171,7 @@ namespace Spectre.Console
if (ShowChoices && Choices.Count > 0) if (ShowChoices && Choices.Count > 0)
{ {
var choices = string.Join("/", Choices.Select(choice => TextPrompt<T>.GetTypeConverter().ConvertToInvariantString(choice))); var choices = string.Join("/", Choices.Select(choice => TypeConverterHelper.ConvertToString(choice)));
builder.AppendFormat(CultureInfo.InvariantCulture, " [blue][[{0}]][/]", choices); builder.AppendFormat(CultureInfo.InvariantCulture, " [blue][[{0}]][/]", choices);
} }
@ -200,7 +180,7 @@ namespace Spectre.Console
builder.AppendFormat( builder.AppendFormat(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
" [green]({0})[/]", " [green]({0})[/]",
TextPrompt<T>.GetTypeConverter().ConvertToInvariantString(DefaultValue.Value)); TypeConverterHelper.ConvertToString(DefaultValue.Value));
} }
var markup = builder.ToString().Trim(); var markup = builder.ToString().Trim();
@ -213,58 +193,6 @@ namespace Spectre.Console
console.Markup(markup + " "); console.Markup(markup + " ");
} }
/// <summary>
/// Tries to convert the input string to <typeparamref name="T"/>.
/// </summary>
/// <param name="input">The input to convert.</param>
/// <param name="result">The result.</param>
/// <returns><c>true</c> if the conversion succeeded, otherwise <c>false</c>.</returns>
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private static bool TryConvert(string input, [MaybeNull] out T result)
{
try
{
result = (T)TextPrompt<T>.GetTypeConverter().ConvertFromInvariantString(input);
return true;
}
catch
{
#pragma warning disable CS8601 // Possible null reference assignment.
result = default;
#pragma warning restore CS8601 // Possible null reference assignment.
return false;
}
}
/// <summary>
/// Gets the type converter that's used to convert values.
/// </summary>
/// <returns>The type converter that's used to convert values.</returns>
private static TypeConverter GetTypeConverter()
{
var converter = TypeDescriptor.GetConverter(typeof(T));
if (converter != null)
{
return converter;
}
var attribute = typeof(T).GetCustomAttribute<TypeConverterAttribute>();
if (attribute != null)
{
var type = Type.GetType(attribute.ConverterTypeName, false, false);
if (type != null)
{
converter = Activator.CreateInstance(type) as TypeConverter;
if (converter != null)
{
return converter;
}
}
}
throw new InvalidOperationException("Could not find type converter");
}
private bool ValidateResult(T value, [NotNullWhen(false)] out string? message) private bool ValidateResult(T value, [NotNullWhen(false)] out string? message)
{ {
if (Validator != null) if (Validator != null)

View File

@ -177,7 +177,7 @@ namespace Spectre.Console
throw new ArgumentNullException(nameof(obj)); throw new ArgumentNullException(nameof(obj));
} }
obj.DefaultValue = new TextPrompt<T>.DefaultValueContainer(value); obj.DefaultValue = new DefaultPromptValue<T>(value);
return obj; return obj;
} }

View File

@ -1,7 +1,7 @@
namespace Spectre.Console namespace Spectre.Console
{ {
/// <summary> /// <summary>
/// Represents a validation result. /// Represents a prompt validation result.
/// </summary> /// </summary>
public sealed class ValidationResult public sealed class ValidationResult
{ {