Skip to content

Commit

Permalink
Add support for synchronous prompt cancellation
Browse files Browse the repository at this point in the history
The current prompt APIs suffer from a few problems.

* The syncrhonous `ReadKey` method does not support cancellation.
* The syncrhonous `ReadKey` method returns a nullable `ConsoleKeyInfo` struct but `System.Console.ReadKey` can never return a nullable `ConsoleKeyInfo`.
* The asyncrhonous `ReadKeyAsync` method can return `null` only if cancellation has been requested. But this can never actually happen since [Fix deadlock when cancelling prompts (spectreconsole#1439)](spectreconsole#1439) was merged.

Here's are the problematic implementation (before this commit fixes it):

```csharp
public ConsoleKeyInfo? ReadKey(bool intercept)
{
    return System.Console.ReadKey(intercept);
}

public async Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
{
    while (true)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            return null;
        }

        if (System.Console.KeyAvailable)
        {
            break;
        }

        await Task.Delay(5, cancellationToken).ConfigureAwait(false);
    }

    return ReadKey(intercept);
}
```

Note that adding a `CancellationToken` parameter and returning `ConsoleKeyInfo` instead of `ConsoleKeyInfo?` is a breaking change since it modifies the signatures of the public `IAnsiConsoleInput` interface. But this should not be an issue since it's impossible to use another implentation than `DefaultInput` when used through `AnsiConsole.Create(AnsiConsoleSettings settings)`.  

I have also searched for [implementers of IAnsiConsoleInput](https://grep.app/search?q=IAnsiConsoleInput) and I think this change won't break anything since nobody actually implemented `IAnsiConsoleInput`. Only exising implementations which have been udated are being used (at least across a half million public git repos).

The addition of the `CancellationToken` to `IPrompt.Show(IAnsiConsole console, CancellationToken cancellationToken = default)` is also a breaking change but it should be mitigated since it has bee introduced with a default value.
  • Loading branch information
0xced committed Sep 11, 2024
1 parent c70a8b8 commit 5c449a0
Show file tree
Hide file tree
Showing 13 changed files with 364 additions and 122 deletions.
8 changes: 5 additions & 3 deletions src/Spectre.Console.Testing/TestConsoleInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,21 @@ public bool IsKeyAvailable()
}

/// <inheritdoc/>
public ConsoleKeyInfo? ReadKey(bool intercept)
public ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken)
{
if (_input.Count == 0)
{
throw new InvalidOperationException("No input available.");
}

cancellationToken.ThrowIfCancellationRequested();

return _input.Dequeue();
}

/// <inheritdoc/>
public Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
public Task<ConsoleKeyInfo> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
{
return Task.FromResult(ReadKey(intercept));
return Task.FromResult(ReadKey(intercept, cancellationToken));
}
}
20 changes: 12 additions & 8 deletions src/Spectre.Console/AnsiConsole.Prompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,28 @@ public static partial class AnsiConsole
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="prompt">The prompt to display.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Prompt<T>(IPrompt<T> prompt)
public static T Prompt<T>(IPrompt<T> prompt, CancellationToken cancellationToken = default)
{
if (prompt is null)
{
throw new ArgumentNullException(nameof(prompt));
}

return prompt.Show(Console);
return prompt.Show(Console, cancellationToken);
}

/// <summary>
/// Displays a prompt to the user.
/// </summary>
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Ask<T>(string prompt)
public static T Ask<T>(string prompt, CancellationToken cancellationToken = default)
{
return new TextPrompt<T>(prompt).Show(Console);
return new TextPrompt<T>(prompt).Show(Console, cancellationToken);
}

/// <summary>
Expand All @@ -38,26 +40,28 @@ public static T Ask<T>(string prompt)
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="defaultValue">The default value.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Ask<T>(string prompt, T defaultValue)
public static T Ask<T>(string prompt, T defaultValue, CancellationToken cancellationToken = default)
{
return new TextPrompt<T>(prompt)
.DefaultValue(defaultValue)
.Show(Console);
.Show(Console, cancellationToken);
}

/// <summary>
/// Displays a prompt with two choices, yes or no.
/// </summary>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="defaultValue">Specifies the default answer.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns><c>true</c> if the user selected "yes", otherwise <c>false</c>.</returns>
public static bool Confirm(string prompt, bool defaultValue = true)
public static bool Confirm(string prompt, bool defaultValue = true, CancellationToken cancellationToken = default)
{
return new ConfirmationPrompt(prompt)
{
DefaultValue = defaultValue,
}
.Show(Console);
.Show(Console, cancellationToken);
}
}
25 changes: 19 additions & 6 deletions src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ 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, CancellationToken cancellationToken = default)
internal static string ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
{
return ReadLineImpl(console, style, secret, mask, async: false, items, cancellationToken).GetAwaiter().GetResult();
}

internal static async Task<string> ReadLineAsync(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
{
return await ReadLineImpl(console, style, secret, mask, async: true, items, cancellationToken).ConfigureAwait(false);
}

[SuppressMessage("ReSharper", "MethodHasAsyncOverload")]
private static async Task<string> ReadLineImpl(IAnsiConsole console, Style? style, bool secret, char? mask, bool async, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
{
if (console is null)
{
Expand All @@ -19,14 +30,16 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st

while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var rawKey = await console.Input.ReadKeyAsync(true, cancellationToken).ConfigureAwait(false);
if (rawKey == null)
ConsoleKeyInfo key;
if (async)
{
continue;
key = await console.Input.ReadKeyAsync(true, cancellationToken).ConfigureAwait(false);
}
else
{
key = console.Input.ReadKey(true, cancellationToken);
}

var key = rawKey.Value;
if (key.Key == ConsoleKey.Enter)
{
return text;
Expand Down
20 changes: 12 additions & 8 deletions src/Spectre.Console/Extensions/AnsiConsoleExtensions.Prompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ public static partial class AnsiConsoleExtensions
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="console">The console.</param>
/// <param name="prompt">The prompt to display.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Prompt<T>(this IAnsiConsole console, IPrompt<T> prompt)
public static T Prompt<T>(this IAnsiConsole console, IPrompt<T> prompt, CancellationToken cancellationToken = default)
{
if (prompt is null)
{
throw new ArgumentNullException(nameof(prompt));
}

return prompt.Show(console);
return prompt.Show(console, cancellationToken);
}

/// <summary>
Expand All @@ -28,10 +29,11 @@ public static T Prompt<T>(this IAnsiConsole console, IPrompt<T> prompt)
/// <typeparam name="T">The prompt result type.</typeparam>
/// <param name="console">The console.</param>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Ask<T>(this IAnsiConsole console, string prompt)
public static T Ask<T>(this IAnsiConsole console, string prompt, CancellationToken cancellationToken = default)
{
return new TextPrompt<T>(prompt).Show(console);
return new TextPrompt<T>(prompt).Show(console, cancellationToken);
}

/// <summary>
Expand All @@ -41,12 +43,13 @@ public static T Ask<T>(this IAnsiConsole console, string prompt)
/// <param name="console">The console.</param>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="culture">Specific CultureInfo to use when converting input.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? culture)
public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? culture, CancellationToken cancellationToken = default)
{
var textPrompt = new TextPrompt<T>(prompt);
textPrompt.Culture = culture;
return textPrompt.Show(console);
return textPrompt.Show(console, cancellationToken);
}

/// <summary>
Expand All @@ -55,13 +58,14 @@ public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? cu
/// <param name="console">The console.</param>
/// <param name="prompt">The prompt markup text.</param>
/// <param name="defaultValue">Specifies the default answer.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns><c>true</c> if the user selected "yes", otherwise <c>false</c>.</returns>
public static bool Confirm(this IAnsiConsole console, string prompt, bool defaultValue = true)
public static bool Confirm(this IAnsiConsole console, string prompt, bool defaultValue = true, CancellationToken cancellationToken = default)
{
return new ConfirmationPrompt(prompt)
{
DefaultValue = defaultValue,
}
.Show(console);
.Show(console, cancellationToken);
}
}
18 changes: 12 additions & 6 deletions src/Spectre.Console/IAnsiConsoleInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,30 @@ namespace Spectre.Console;
public interface IAnsiConsoleInput
{
/// <summary>
/// Gets a value indicating whether or not
/// there is a key available.
/// Gets a value indicating whether there is a key available or not.
/// </summary>
/// <returns><c>true</c> if there's a key available, otherwise <c>false</c>.</returns>
bool IsKeyAvailable();

/// <summary>
/// Reads a key from the console.
/// </summary>
/// <param name="intercept">Whether or not to intercept the key.</param>
/// <param name="intercept">
/// Determines whether to display the pressed key in the console window.
/// <see langword="true"/> to not display the pressed key; otherwise, <see langword="false"/>.
/// </param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The key that was read.</returns>
ConsoleKeyInfo? ReadKey(bool intercept);
ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken);

/// <summary>
/// Reads a key from the console.
/// </summary>
/// <param name="intercept">Whether or not to intercept the key.</param>
/// <param name="intercept">
/// Determines whether to display the pressed key in the console window.
/// <see langword="true"/> to not display the pressed key; otherwise, <see langword="false"/>.
/// </param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The key that was read.</returns>
Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken);
Task<ConsoleKeyInfo> ReadKeyAsync(bool intercept, CancellationToken cancellationToken);
}
47 changes: 23 additions & 24 deletions src/Spectre.Console/Internal/DefaultInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,45 @@ public DefaultInput(Profile profile)

public bool IsKeyAvailable()
{
if (!_profile.Capabilities.Interactive)
{
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
}
EnsureInteractive();

return System.Console.KeyAvailable;
}

public ConsoleKeyInfo? ReadKey(bool intercept)
public ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken)
{
if (!_profile.Capabilities.Interactive)
cancellationToken.ThrowIfCancellationRequested();

EnsureInteractive();

while (!System.Console.KeyAvailable)
{
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(5);
}

return System.Console.ReadKey(intercept);
}

public async Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
public async Task<ConsoleKeyInfo> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
{
if (!_profile.Capabilities.Interactive)
{
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
}
cancellationToken.ThrowIfCancellationRequested();

while (true)
{
if (cancellationToken.IsCancellationRequested)
{
return null;
}

if (System.Console.KeyAvailable)
{
break;
}
EnsureInteractive();

while (!System.Console.KeyAvailable)
{
await Task.Delay(5, cancellationToken).ConfigureAwait(false);
}

return ReadKey(intercept);
return System.Console.ReadKey(intercept);
}

private void EnsureInteractive()
{
if (!_profile.Capabilities.Interactive)
{
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
}
}
}
19 changes: 16 additions & 3 deletions src/Spectre.Console/Prompts/ConfirmationPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,18 @@ public ConfirmationPrompt(string prompt)
}

/// <inheritdoc/>
public bool Show(IAnsiConsole console)
public bool Show(IAnsiConsole console, CancellationToken cancellationToken = default)
{
return ShowAsync(console, CancellationToken.None).GetAwaiter().GetResult();
return ShowImpl(console, async: false, cancellationToken).GetAwaiter().GetResult();
}

/// <inheritdoc/>
public async Task<bool> ShowAsync(IAnsiConsole console, CancellationToken cancellationToken)
{
return await ShowImpl(console, async: true, cancellationToken).ConfigureAwait(false);
}

private async Task<bool> ShowImpl(IAnsiConsole console, bool async, CancellationToken cancellationToken)
{
var comparer = Comparer ?? StringComparer.CurrentCultureIgnoreCase;

Expand All @@ -89,7 +94,15 @@ public async Task<bool> ShowAsync(IAnsiConsole console, CancellationToken cancel
.AddChoice(Yes)
.AddChoice(No);

var result = await prompt.ShowAsync(console, cancellationToken).ConfigureAwait(false);
char result;
if (async)
{
result = await prompt.ShowAsync(console, cancellationToken).ConfigureAwait(false);
}
else
{
result = prompt.Show(console, cancellationToken);
}

return comparer.Compare(Yes.ToString(), result.ToString()) == 0;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Spectre.Console/Prompts/IPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public interface IPrompt<T>
/// Shows the prompt.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>The prompt input result.</returns>
T Show(IAnsiConsole console);
T Show(IAnsiConsole console, CancellationToken cancellationToken = default);

/// <summary>
/// Shows the prompt asynchronously.
Expand Down
Loading

0 comments on commit 5c449a0

Please sign in to comment.