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
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@
public System.StringComparer Comparer { get; set; }
public bool DefaultValue { get; set; }
public Spectre.Console.Style? DefaultValueStyle { get; set; }
public Spectre.Console.PromptHistory? History { get; set; }
public string InvalidChoiceMessage { get; set; }
public char No { get; set; }
public bool RequireEnter { get; set; }
Expand All @@ -392,6 +393,7 @@
public static Spectre.Console.ConfirmationPrompt DefaultValueStyle(this Spectre.Console.ConfirmationPrompt obj, Spectre.Console.Style? style) { }
public static Spectre.Console.ConfirmationPrompt HideChoices(this Spectre.Console.ConfirmationPrompt obj) { }
public static Spectre.Console.ConfirmationPrompt HideDefaultValue(this Spectre.Console.ConfirmationPrompt obj) { }
public static Spectre.Console.ConfirmationPrompt History(this Spectre.Console.ConfirmationPrompt obj, Spectre.Console.PromptHistory? history) { }
public static Spectre.Console.ConfirmationPrompt InvalidChoiceMessage(this Spectre.Console.ConfirmationPrompt obj, string message) { }
public static Spectre.Console.ConfirmationPrompt No(this Spectre.Console.ConfirmationPrompt obj, char character) { }
public static Spectre.Console.ConfirmationPrompt RequireEnter(this Spectre.Console.ConfirmationPrompt obj, bool require = true) { }
Expand Down Expand Up @@ -2639,6 +2641,17 @@
public T Update<T>(string key, System.Func<T, T> func)
where T : struct { }
}
public sealed class PromptHistory
{
public PromptHistory(int capacity = 32) { }
public bool Enabled { get; set; }
public System.Collections.Generic.IReadOnlyList<string> Entries { get; }
public bool IgnoreSecret { get; set; }
public static Spectre.Console.PromptHistory Default { get; }
public event System.EventHandler<string>? EntryAdded;
public void Add(string entry) { }
public void Clear() { }
}
public class Recorder : Spectre.Console.IAnsiConsole, System.IDisposable
{
public Recorder(Spectre.Console.IAnsiConsole console) { }
Expand Down Expand Up @@ -3120,9 +3133,11 @@
public static Spectre.Console.TextPrompt<T> ClearOnFinish<T>(this Spectre.Console.TextPrompt<T> obj, bool clear = true) { }
public static Spectre.Console.TextPrompt<T> DefaultValue<T>(this Spectre.Console.TextPrompt<T> obj, T value) { }
public static Spectre.Console.TextPrompt<T> DefaultValueStyle<T>(this Spectre.Console.TextPrompt<T> obj, Spectre.Console.Style? style) { }
public static Spectre.Console.TextPrompt<T> DisableHistory<T>(this Spectre.Console.TextPrompt<T> obj) { }
public static Spectre.Console.TextPrompt<T> EditableDefaultValue<T>(this Spectre.Console.TextPrompt<T> obj, bool state) { }
public static Spectre.Console.TextPrompt<T> HideChoices<T>(this Spectre.Console.TextPrompt<T> obj) { }
public static Spectre.Console.TextPrompt<T> HideDefaultValue<T>(this Spectre.Console.TextPrompt<T> obj) { }
public static Spectre.Console.TextPrompt<T> History<T>(this Spectre.Console.TextPrompt<T> obj, Spectre.Console.PromptHistory? history) { }
public static Spectre.Console.TextPrompt<T> InvalidChoiceMessage<T>(this Spectre.Console.TextPrompt<T> obj, string message) { }
public static Spectre.Console.TextPrompt<T> PromptStyle<T>(this Spectre.Console.TextPrompt<T> obj, Spectre.Console.Style style) { }
public static Spectre.Console.TextPrompt<T> Secret<T>(this Spectre.Console.TextPrompt<T> obj) { }
Expand All @@ -3147,6 +3162,7 @@
public System.Globalization.CultureInfo? Culture { get; set; }
public Spectre.Console.Style? DefaultValueStyle { get; set; }
public bool EditableDefaultValue { get; set; }
public Spectre.Console.PromptHistory? History { get; set; }
public string InvalidChoiceMessage { get; set; }
public bool IsSecret { get; set; }
public char? Mask { get; set; }
Expand Down
82 changes: 82 additions & 0 deletions src/Spectre.Console.Tests/Unit/Prompts/ConfirmationPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public void Should_Not_Require_Enter_If_RequireEnter_Is_Set_To_False(
public void Should_Ignore_Invalid_Input()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushCharacter('a');
console.Input.PushCharacter('b');
Expand All @@ -65,4 +66,85 @@ public void Should_Ignore_Invalid_Input()
result.ShouldBe(true);
console.Input.IsKeyAvailable().ShouldBeFalse();
}

[Fact]
public void Should_Return_True_When_User_Answers_Yes()
{
// Given
var console = new TestConsole();
console.Input.PushTextWithEnter("y");

// When
var result = console.Prompt(new ConfirmationPrompt("Continue?"));

// Then
result.ShouldBe(true);
}

[Fact]
public void Should_Return_False_When_User_Answers_No()
{
// Given
var console = new TestConsole();
console.Input.PushTextWithEnter("n");

// When
var result = console.Prompt(new ConfirmationPrompt("Continue?"));

// Then
result.ShouldBe(false);
}

[Fact]
public void Should_Add_Confirmation_Input_To_History()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("y");

// When
var result = console.Prompt(new ConfirmationPrompt("Continue?") { History = history });

// Then
result.ShouldBe(true);
history.Entries.ShouldBe(new[] { "y" });
}

[Fact]
public void Should_Share_History_Between_TextPrompt_And_ConfirmationPrompt()
{
// Given
var sharedHistory = new PromptHistory();
var console = new TestConsole();

// First, a text prompt
console.Input.PushTextWithEnter("hello");
console.Prompt(new TextPrompt<string>("Enter text:") { History = sharedHistory });

// Then, a confirmation prompt
console.Input.PushTextWithEnter("n");
var confirmResult = console.Prompt(new ConfirmationPrompt("Continue?") { History = sharedHistory });

// Then
confirmResult.ShouldBe(false);
sharedHistory.Entries.ShouldBe(new[] { "hello", "n" });
}

[Fact]
public void Should_Not_Add_Invalid_Confirmation_Input_To_History()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("maybe");
console.Input.PushTextWithEnter("y");

// When
var result = console.Prompt(new ConfirmationPrompt("Continue?") { History = history });

// Then
result.ShouldBe(true);
history.Entries.ShouldBe(new[] { "y" }); // Only the valid "y" is stored
}
}
84 changes: 84 additions & 0 deletions src/Spectre.Console.Tests/Unit/Prompts/TextPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,90 @@ public void Should_Return_Entered_Text()
result.ShouldBe("Hello World");
}

[Fact]
public void Should_Add_Valid_Text_To_History()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("Hello World");
// When
var result = console.Prompt(new TextPrompt<string>("Enter text:") { History = history });

// Then
result.ShouldBe("Hello World");
history.Entries.ShouldBe(new[] { "Hello World" });
}

[Fact]
public void Should_Not_Add_Invalid_Text_To_History()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("bad");
console.Input.PushTextWithEnter("99");

// When
console.Prompt(new TextPrompt<int>("Age?") { History = history });

// Then
history.Entries.ShouldBe(new[] { "99" });
}

[Fact]
public void Should_Not_Add_Secret_Text_To_History_By_Default()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("secret");

// When
var result = console.Prompt(new TextPrompt<string>("Password?") { History = history }.Secret());

// Then
result.ShouldBe("secret");
history.Entries.ShouldBeEmpty();
}

[Fact]
public void Should_Cycle_Through_History_With_Arrow_Keys()
{
// Given
var history = new PromptHistory();
history.Add("first");
history.Add("second");

var console = new TestConsole();
console.Input.PushKey(ConsoleKey.UpArrow);
console.Input.PushKey(ConsoleKey.UpArrow);
console.Input.PushKey(ConsoleKey.DownArrow);
console.Input.PushKey(ConsoleKey.Enter);

// When
var result = console.Prompt(new TextPrompt<string>("Enter text:") { History = history });

// Then
result.ShouldBe("second");
}

[Fact]
public void Should_Store_Confirmation_Input_In_History()
{
// Given
var history = new PromptHistory();
var console = new TestConsole();
console.Input.PushTextWithEnter("y");

// When
var result = console.Prompt(new ConfirmationPrompt("Continue?") { History = history });

// Then
result.ShouldBe(true);
history.Entries.ShouldBe(new[] { "y" });
}

[Fact]
[Expectation("ConversionError")]
public Task Should_Return_Validation_Error_If_Value_Cannot_Be_Converted()
Expand Down
89 changes: 83 additions & 6 deletions src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ namespace Spectre.Console;
/// </summary>
public static partial class AnsiConsoleExtensions
{
internal static async Task<string> ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask,
IEnumerable<string>? items = null, string? initialInput = null, CancellationToken cancellationToken = default)
internal static async Task<string> ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default, string? initialInput = null, PromptHistory? history = null)
{
ArgumentNullException.ThrowIfNull(console);

style ??= Style.Plain;
var text = string.Empty;

var autocomplete = new List<string>(items ?? []);
var historyEntries = history?.Enabled == true ? history.Entries : Array.Empty<string>();
var historyIndex = -1;
string? pendingText = null;

Queue<ConsoleKeyInfo>? injectedQueue = null;
if (!string.IsNullOrEmpty(initialInput))
Expand Down Expand Up @@ -51,6 +53,48 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st
return text;
}

if (key.Key == ConsoleKey.UpArrow && historyEntries.Count > 0)
{
if (historyIndex == -1)
{
pendingText = text;
historyIndex = historyEntries.Count - 1;
}
else if (historyIndex > 0)
{
historyIndex--;
}

var replace = historyEntries[historyIndex];
ReplaceLine(console, style, text, replace, secret, mask);
text = replace;
continue;
}

if (key.Key == ConsoleKey.DownArrow && historyEntries.Count > 0)
{
if (historyIndex == -1)
{
continue;
}

if (historyIndex < historyEntries.Count - 1)
{
historyIndex++;
var replace = historyEntries[historyIndex];
ReplaceLine(console, style, text, replace, secret, mask);
text = replace;
continue;
}

var restore = pendingText ?? string.Empty;
ReplaceLine(console, style, text, restore, secret, mask);
text = restore;
pendingText = null;
historyIndex = -1;
continue;
}

if (key.Key == ConsoleKey.Tab && autocomplete.Count > 0)
{
var autoCompleteDirection = key.Modifiers.HasFlag(ConsoleModifiers.Shift)
Expand All @@ -69,6 +113,9 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st

if (key.Key == ConsoleKey.Backspace)
{
historyIndex = -1;
pendingText = null;

if (text.Length > 0)
{
var lastChar = text.Last();
Expand All @@ -92,15 +139,16 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st

if (!char.IsControl(key.KeyChar))
{
historyIndex = -1;
pendingText = null;
text += key.KeyChar.ToString();
var output = key.KeyChar.ToString();
console.Write(secret ? output.Mask(mask) : output, style);
}
}
}

private static string AutoComplete(List<string> autocomplete, string text,
AutoCompleteDirection autoCompleteDirection)
private static string AutoComplete(List<string> autocomplete, string text, AutoCompleteDirection autoCompleteDirection)
{
var found = autocomplete.Find(i => i == text);
var replace = string.Empty;
Expand Down Expand Up @@ -128,8 +176,37 @@ private static string AutoComplete(List<string> autocomplete, string text,
return replace;
}

private static string GetAutocompleteValue(AutoCompleteDirection autoCompleteDirection, IList<string> autocomplete,
string found)
private static void ReplaceLine(IAnsiConsole console, Style? style, string currentText, string replace, bool secret, char? mask)
{
if (!string.IsNullOrEmpty(currentText) && !(secret && mask is null))
{
if (mask is null)
{
console.Write("\b \b".Repeat(currentText.Length), style);
}
else
{
foreach (var c in currentText)
{
if (UnicodeCalculator.GetWidth(c) == 1)
{
console.Write("\b \b", style);
}
else if (UnicodeCalculator.GetWidth(c) == 2)
{
console.Write("\b \b\b \b", style);
}
}
}
}

if (!string.IsNullOrEmpty(replace) && !(secret && mask is null))
{
console.Write(secret ? replace.Mask(mask) : replace, style);
}
}

private static string GetAutocompleteValue(AutoCompleteDirection autoCompleteDirection, IList<string> autocomplete, string found)
{
var foundAutocompleteIndex = autocomplete.IndexOf(found);
var index = autoCompleteDirection switch
Expand Down
Loading