Skip to content

Commit 5c449a0

Browse files
committed
Add support for synchronous prompt cancellation
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.
1 parent c70a8b8 commit 5c449a0

13 files changed

Lines changed: 364 additions & 122 deletions

File tree

src/Spectre.Console.Testing/TestConsoleInput.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,21 @@ public bool IsKeyAvailable()
7777
}
7878

7979
/// <inheritdoc/>
80-
public ConsoleKeyInfo? ReadKey(bool intercept)
80+
public ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken)
8181
{
8282
if (_input.Count == 0)
8383
{
8484
throw new InvalidOperationException("No input available.");
8585
}
8686

87+
cancellationToken.ThrowIfCancellationRequested();
88+
8789
return _input.Dequeue();
8890
}
8991

9092
/// <inheritdoc/>
91-
public Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
93+
public Task<ConsoleKeyInfo> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
9294
{
93-
return Task.FromResult(ReadKey(intercept));
95+
return Task.FromResult(ReadKey(intercept, cancellationToken));
9496
}
9597
}

src/Spectre.Console/AnsiConsole.Prompt.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,28 @@ public static partial class AnsiConsole
1010
/// </summary>
1111
/// <typeparam name="T">The prompt result type.</typeparam>
1212
/// <param name="prompt">The prompt to display.</param>
13+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
1314
/// <returns>The prompt input result.</returns>
14-
public static T Prompt<T>(IPrompt<T> prompt)
15+
public static T Prompt<T>(IPrompt<T> prompt, CancellationToken cancellationToken = default)
1516
{
1617
if (prompt is null)
1718
{
1819
throw new ArgumentNullException(nameof(prompt));
1920
}
2021

21-
return prompt.Show(Console);
22+
return prompt.Show(Console, cancellationToken);
2223
}
2324

2425
/// <summary>
2526
/// Displays a prompt to the user.
2627
/// </summary>
2728
/// <typeparam name="T">The prompt result type.</typeparam>
2829
/// <param name="prompt">The prompt markup text.</param>
30+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
2931
/// <returns>The prompt input result.</returns>
30-
public static T Ask<T>(string prompt)
32+
public static T Ask<T>(string prompt, CancellationToken cancellationToken = default)
3133
{
32-
return new TextPrompt<T>(prompt).Show(Console);
34+
return new TextPrompt<T>(prompt).Show(Console, cancellationToken);
3335
}
3436

3537
/// <summary>
@@ -38,26 +40,28 @@ public static T Ask<T>(string prompt)
3840
/// <typeparam name="T">The prompt result type.</typeparam>
3941
/// <param name="prompt">The prompt markup text.</param>
4042
/// <param name="defaultValue">The default value.</param>
43+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
4144
/// <returns>The prompt input result.</returns>
42-
public static T Ask<T>(string prompt, T defaultValue)
45+
public static T Ask<T>(string prompt, T defaultValue, CancellationToken cancellationToken = default)
4346
{
4447
return new TextPrompt<T>(prompt)
4548
.DefaultValue(defaultValue)
46-
.Show(Console);
49+
.Show(Console, cancellationToken);
4750
}
4851

4952
/// <summary>
5053
/// Displays a prompt with two choices, yes or no.
5154
/// </summary>
5255
/// <param name="prompt">The prompt markup text.</param>
5356
/// <param name="defaultValue">Specifies the default answer.</param>
57+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
5458
/// <returns><c>true</c> if the user selected "yes", otherwise <c>false</c>.</returns>
55-
public static bool Confirm(string prompt, bool defaultValue = true)
59+
public static bool Confirm(string prompt, bool defaultValue = true, CancellationToken cancellationToken = default)
5660
{
5761
return new ConfirmationPrompt(prompt)
5862
{
5963
DefaultValue = defaultValue,
6064
}
61-
.Show(Console);
65+
.Show(Console, cancellationToken);
6266
}
6367
}

src/Spectre.Console/Extensions/AnsiConsoleExtensions.Input.cs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ namespace Spectre.Console;
55
/// </summary>
66
public static partial class AnsiConsoleExtensions
77
{
8-
internal static async Task<string> ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
8+
internal static string ReadLine(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
9+
{
10+
return ReadLineImpl(console, style, secret, mask, async: false, items, cancellationToken).GetAwaiter().GetResult();
11+
}
12+
13+
internal static async Task<string> ReadLineAsync(this IAnsiConsole console, Style? style, bool secret, char? mask, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
14+
{
15+
return await ReadLineImpl(console, style, secret, mask, async: true, items, cancellationToken).ConfigureAwait(false);
16+
}
17+
18+
[SuppressMessage("ReSharper", "MethodHasAsyncOverload")]
19+
private static async Task<string> ReadLineImpl(IAnsiConsole console, Style? style, bool secret, char? mask, bool async, IEnumerable<string>? items = null, CancellationToken cancellationToken = default)
920
{
1021
if (console is null)
1122
{
@@ -19,14 +30,16 @@ internal static async Task<string> ReadLine(this IAnsiConsole console, Style? st
1930

2031
while (true)
2132
{
22-
cancellationToken.ThrowIfCancellationRequested();
23-
var rawKey = await console.Input.ReadKeyAsync(true, cancellationToken).ConfigureAwait(false);
24-
if (rawKey == null)
33+
ConsoleKeyInfo key;
34+
if (async)
2535
{
26-
continue;
36+
key = await console.Input.ReadKeyAsync(true, cancellationToken).ConfigureAwait(false);
37+
}
38+
else
39+
{
40+
key = console.Input.ReadKey(true, cancellationToken);
2741
}
2842

29-
var key = rawKey.Value;
3043
if (key.Key == ConsoleKey.Enter)
3144
{
3245
return text;

src/Spectre.Console/Extensions/AnsiConsoleExtensions.Prompt.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ public static partial class AnsiConsoleExtensions
1111
/// <typeparam name="T">The prompt result type.</typeparam>
1212
/// <param name="console">The console.</param>
1313
/// <param name="prompt">The prompt to display.</param>
14+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
1415
/// <returns>The prompt input result.</returns>
15-
public static T Prompt<T>(this IAnsiConsole console, IPrompt<T> prompt)
16+
public static T Prompt<T>(this IAnsiConsole console, IPrompt<T> prompt, CancellationToken cancellationToken = default)
1617
{
1718
if (prompt is null)
1819
{
1920
throw new ArgumentNullException(nameof(prompt));
2021
}
2122

22-
return prompt.Show(console);
23+
return prompt.Show(console, cancellationToken);
2324
}
2425

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

3739
/// <summary>
@@ -41,12 +43,13 @@ public static T Ask<T>(this IAnsiConsole console, string prompt)
4143
/// <param name="console">The console.</param>
4244
/// <param name="prompt">The prompt markup text.</param>
4345
/// <param name="culture">Specific CultureInfo to use when converting input.</param>
46+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
4447
/// <returns>The prompt input result.</returns>
45-
public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? culture)
48+
public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? culture, CancellationToken cancellationToken = default)
4649
{
4750
var textPrompt = new TextPrompt<T>(prompt);
4851
textPrompt.Culture = culture;
49-
return textPrompt.Show(console);
52+
return textPrompt.Show(console, cancellationToken);
5053
}
5154

5255
/// <summary>
@@ -55,13 +58,14 @@ public static T Ask<T>(this IAnsiConsole console, string prompt, CultureInfo? cu
5558
/// <param name="console">The console.</param>
5659
/// <param name="prompt">The prompt markup text.</param>
5760
/// <param name="defaultValue">Specifies the default answer.</param>
61+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
5862
/// <returns><c>true</c> if the user selected "yes", otherwise <c>false</c>.</returns>
59-
public static bool Confirm(this IAnsiConsole console, string prompt, bool defaultValue = true)
63+
public static bool Confirm(this IAnsiConsole console, string prompt, bool defaultValue = true, CancellationToken cancellationToken = default)
6064
{
6165
return new ConfirmationPrompt(prompt)
6266
{
6367
DefaultValue = defaultValue,
6468
}
65-
.Show(console);
69+
.Show(console, cancellationToken);
6670
}
6771
}

src/Spectre.Console/IAnsiConsoleInput.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,30 @@ namespace Spectre.Console;
66
public interface IAnsiConsoleInput
77
{
88
/// <summary>
9-
/// Gets a value indicating whether or not
10-
/// there is a key available.
9+
/// Gets a value indicating whether there is a key available or not.
1110
/// </summary>
1211
/// <returns><c>true</c> if there's a key available, otherwise <c>false</c>.</returns>
1312
bool IsKeyAvailable();
1413

1514
/// <summary>
1615
/// Reads a key from the console.
1716
/// </summary>
18-
/// <param name="intercept">Whether or not to intercept the key.</param>
17+
/// <param name="intercept">
18+
/// Determines whether to display the pressed key in the console window.
19+
/// <see langword="true"/> to not display the pressed key; otherwise, <see langword="false"/>.
20+
/// </param>
21+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
1922
/// <returns>The key that was read.</returns>
20-
ConsoleKeyInfo? ReadKey(bool intercept);
23+
ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken);
2124

2225
/// <summary>
2326
/// Reads a key from the console.
2427
/// </summary>
25-
/// <param name="intercept">Whether or not to intercept the key.</param>
28+
/// <param name="intercept">
29+
/// Determines whether to display the pressed key in the console window.
30+
/// <see langword="true"/> to not display the pressed key; otherwise, <see langword="false"/>.
31+
/// </param>
2632
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
2733
/// <returns>The key that was read.</returns>
28-
Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken);
34+
Task<ConsoleKeyInfo> ReadKeyAsync(bool intercept, CancellationToken cancellationToken);
2935
}

src/Spectre.Console/Internal/DefaultInput.cs

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,46 +11,45 @@ public DefaultInput(Profile profile)
1111

1212
public bool IsKeyAvailable()
1313
{
14-
if (!_profile.Capabilities.Interactive)
15-
{
16-
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
17-
}
14+
EnsureInteractive();
1815

1916
return System.Console.KeyAvailable;
2017
}
2118

22-
public ConsoleKeyInfo? ReadKey(bool intercept)
19+
public ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken)
2320
{
24-
if (!_profile.Capabilities.Interactive)
21+
cancellationToken.ThrowIfCancellationRequested();
22+
23+
EnsureInteractive();
24+
25+
while (!System.Console.KeyAvailable)
2526
{
26-
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
27+
cancellationToken.ThrowIfCancellationRequested();
28+
Thread.Sleep(5);
2729
}
2830

2931
return System.Console.ReadKey(intercept);
3032
}
3133

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

39-
while (true)
40-
{
41-
if (cancellationToken.IsCancellationRequested)
42-
{
43-
return null;
44-
}
45-
46-
if (System.Console.KeyAvailable)
47-
{
48-
break;
49-
}
38+
EnsureInteractive();
5039

40+
while (!System.Console.KeyAvailable)
41+
{
5142
await Task.Delay(5, cancellationToken).ConfigureAwait(false);
5243
}
5344

54-
return ReadKey(intercept);
45+
return System.Console.ReadKey(intercept);
46+
}
47+
48+
private void EnsureInteractive()
49+
{
50+
if (!_profile.Capabilities.Interactive)
51+
{
52+
throw new InvalidOperationException("Failed to read input in non-interactive mode.");
53+
}
5554
}
5655
}

src/Spectre.Console/Prompts/ConfirmationPrompt.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,18 @@ public ConfirmationPrompt(string prompt)
6868
}
6969

7070
/// <inheritdoc/>
71-
public bool Show(IAnsiConsole console)
71+
public bool Show(IAnsiConsole console, CancellationToken cancellationToken = default)
7272
{
73-
return ShowAsync(console, CancellationToken.None).GetAwaiter().GetResult();
73+
return ShowImpl(console, async: false, cancellationToken).GetAwaiter().GetResult();
7474
}
7575

7676
/// <inheritdoc/>
7777
public async Task<bool> ShowAsync(IAnsiConsole console, CancellationToken cancellationToken)
78+
{
79+
return await ShowImpl(console, async: true, cancellationToken).ConfigureAwait(false);
80+
}
81+
82+
private async Task<bool> ShowImpl(IAnsiConsole console, bool async, CancellationToken cancellationToken)
7883
{
7984
var comparer = Comparer ?? StringComparer.CurrentCultureIgnoreCase;
8085

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

92-
var result = await prompt.ShowAsync(console, cancellationToken).ConfigureAwait(false);
97+
char result;
98+
if (async)
99+
{
100+
result = await prompt.ShowAsync(console, cancellationToken).ConfigureAwait(false);
101+
}
102+
else
103+
{
104+
result = prompt.Show(console, cancellationToken);
105+
}
93106

94107
return comparer.Compare(Yes.ToString(), result.ToString()) == 0;
95108
}

src/Spectre.Console/Prompts/IPrompt.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ public interface IPrompt<T>
1010
/// Shows the prompt.
1111
/// </summary>
1212
/// <param name="console">The console.</param>
13+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
1314
/// <returns>The prompt input result.</returns>
14-
T Show(IAnsiConsole console);
15+
T Show(IAnsiConsole console, CancellationToken cancellationToken = default);
1516

1617
/// <summary>
1718
/// Shows the prompt asynchronously.

0 commit comments

Comments
 (0)