Skip to content
Merged
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
2 changes: 1 addition & 1 deletion TUI.App/InteractiveDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ private static BorderElement CreateInstructionsPanel() =>
/// <returns>True when the key was one of the advertised controls, false otherwise</returns>
/// <remarks>
/// Only <see cref="InputType.Keyboard"/> input is acted on, because that is all
/// <see cref="SpectreConsoleProvider.ReadInputAsync"/> produces — every key arrives as
/// <see cref="SpectreConsoleProvider.ReadInputAsync()"/> produces — every key arrives as
/// <see cref="InputResult.FromKey"/>, and Escape arrives as an exit that
/// <see cref="UIApplication.ProcessInputAsync"/> consumes before reaching any element.
/// </remarks>
Expand Down
51 changes: 51 additions & 0 deletions TUI.Test/SpectreConsoleProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
[TestClass]
public sealed class SpectreConsoleProviderTests
{
/// <summary>
/// Gets or sets the test context MSTest injects.
/// </summary>
public TestContext TestContext { get; set; } = null!;

private const int Width = 10;
private const int Height = 5;

Expand Down Expand Up @@ -147,12 +152,58 @@
{
SpectreConsoleProvider provider = new(console: null, () => new ConsoleKeyInfo('!', ConsoleKey.D1, shift: true, alt: false, control: false));

InputResult result = await provider.ReadInputAsync().ConfigureAwait(false);

Check warning on line 155 in TUI.Test/SpectreConsoleProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TUI&issues=AaEbHUltLEUyczWKqRXw&open=AaEbHUltLEUyczWKqRXw&pullRequest=206

Assert.AreEqual(ConsoleKey.D1, result.Key);
Assert.AreEqual('!', result.Character);
}

/// <summary>
/// Tests that a cancellable read waits for a key to be available and then reads it.
/// </summary>
/// <returns>A task that completes when the test has run.</returns>
[TestMethod]
public async Task CancellableReadInputAsyncReadsTheKeyOnceOneIsAvailable()
{
int polls = 0;
SpectreConsoleProvider provider = new(
console: null,
() => new ConsoleKeyInfo('a', ConsoleKey.A, shift: false, alt: false, control: false),
keyAvailable: () => Interlocked.Increment(ref polls) > 2);

InputResult result = await provider.ReadInputAsync(TestContext.CancellationToken).ConfigureAwait(false);

Assert.AreEqual(ConsoleKey.A, result.Key);
}

/// <summary>
/// Tests that cancelling a read that is waiting for a key ends it without reading one, so no
/// thread is left blocked in Console.ReadKey to take the next key (ktsu-dev/TUI#149).
/// </summary>
/// <returns>A task that completes when the test has run.</returns>
[TestMethod]
public async Task CancellingAReadThatIsWaitingEndsItWithoutReadingAKey()
{
int keysRead = 0;
SpectreConsoleProvider provider = new(
console: null,
() =>
{
Interlocked.Increment(ref keysRead);
return new ConsoleKeyInfo('a', ConsoleKey.A, shift: false, alt: false, control: false);
},
keyAvailable: () => false);
using CancellationTokenSource cancellation = new();

Task<InputResult> read = provider.ReadInputAsync(cancellation.Token);
await cancellation.CancelAsync().ConfigureAwait(false);

Task finished = await Task.WhenAny(read, Task.Delay(TimeSpan.FromSeconds(10), TestContext.CancellationToken)).ConfigureAwait(false);
Assert.AreSame(read, finished, "A cancelled read should end");
Assert.IsTrue(read.IsCanceled);
Assert.AreEqual(0, keysRead);
}

/// <summary>
/// Tests that a key with no printable character, such as an arrow or Enter, carries no
/// character.
Expand Down
218 changes: 218 additions & 0 deletions TUI.Test/UIApplicationPendingReadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Contracts;
using ktsu.TUI.Core.Elements;
using ktsu.TUI.Core.Models;
using ktsu.TUI.Core.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that a run does not leave a read behind when it ends, where it would take the next key
/// meant for the host or for a later run (ktsu-dev/TUI#149).
/// </summary>
[TestClass]
public sealed class UIApplicationPendingReadTests
{
/// <summary>
/// How long any single step is given before it is declared stuck.
/// </summary>
private static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(10);

/// <summary>
/// Gets or sets the test context MSTest injects.
/// </summary>
public TestContext TestContext { get; set; } = null!;

/// <summary>
/// A run ended by <see cref="UIApplication.Shutdown"/> gives its read up, so no read is left
/// waiting to take a key the host reads next.
/// </summary>
[TestMethod]
public async Task AShutdownLeavesNoReadOutstanding()
{
KeyboardConsoleProvider provider = new(honoursCancellation: true);
UIApplication app = new(provider) { InterruptSource = new FakeInterruptSource() };
app.Setup(new KeyCountingElement());

Task run = app.RunAsync(TestContext.CancellationToken);
await AssertCompletesAsync(provider.WaitForReadsStartedAsync(1), "The run should start a read").ConfigureAwait(false);
app.Shutdown();
await AssertCompletesAsync(run, "Shutdown should end the run").ConfigureAwait(false);

Assert.AreEqual(0, provider.PendingReadCount, "No read should still be waiting for a key once the run has ended");
}

/// <summary>
/// A key pressed during a second run reaches the root, rather than the read the first run
/// left behind.
/// </summary>
/// <param name="honoursCancellation">Whether the provider gives a read up when asked to.</param>
[TestMethod]
[DataRow(true)]
[DataRow(false)]
public async Task AKeyPressedInASecondRunReachesTheRoot(bool honoursCancellation)
{
KeyboardConsoleProvider provider = new(honoursCancellation);
KeyCountingElement root = new();
UIApplication app = new(provider) { InterruptSource = new FakeInterruptSource() };
app.Setup(root);

Task first = app.RunAsync(TestContext.CancellationToken);
await AssertCompletesAsync(provider.WaitForReadsStartedAsync(1), "The first run should start a read").ConfigureAwait(false);
app.Shutdown();
await AssertCompletesAsync(first, "Shutdown should end the first run").ConfigureAwait(false);

Task second = app.RunAsync(TestContext.CancellationToken);
await AssertCompletesAsync(provider.WaitForPendingReadAsync(), "The second run should be waiting for a key").ConfigureAwait(false);
provider.Press(ConsoleKey.A);
await AssertCompletesAsync(root.FirstKey, "The key should reach the root of the second run").ConfigureAwait(false);
app.Shutdown();
await AssertCompletesAsync(second, "Shutdown should end the second run").ConfigureAwait(false);

Assert.AreEqual(1, root.KeyCount);
}

private async Task AssertCompletesAsync(Task task, string message)
{
Task finished = await Task.WhenAny(task, Task.Delay(StepTimeout, TestContext.CancellationToken)).ConfigureAwait(false);
Assert.AreSame(task, finished, message);
await task.ConfigureAwait(false);
}

/// <summary>
/// An element that counts the keys it is given.
/// </summary>
private sealed class KeyCountingElement : UIElementBase
{
private readonly TaskCompletionSource firstKey = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int keyCount;

public Task FirstKey => firstKey.Task;

public int KeyCount => Volatile.Read(ref keyCount);

public override bool HandleInput(InputResult input)
{
Interlocked.Increment(ref keyCount);
firstKey.TrySetResult();
return true;
}

protected override void OnRender(IConsoleProvider provider)
{
}
}

/// <summary>
/// A provider whose keys go to the earliest read still waiting, as with Console.ReadKey.
/// </summary>
/// <param name="honoursCancellation">Whether a read ends when its token is cancelled, as
/// SpectreConsoleProvider's does, or keeps waiting, as a provider written against the
/// parameterless ReadInputAsync does.</param>
private sealed class KeyboardConsoleProvider(bool honoursCancellation) : IConsoleProvider
{
private readonly Lock gate = new();
private readonly List<TaskCompletionSource<InputResult>> pending = [];
private readonly List<(int Count, TaskCompletionSource Signal)> startedWaiters = [];
private TaskCompletionSource pendingWaiter = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int started;

public int PendingReadCount
{
get
{
lock (gate)
{
return pending.Count(read => !read.Task.IsCompleted);
}
}
}

public Dimensions Dimensions => new(80, 24);

public void Clear() { }

public void Render(IUIElement element, Position position) => element?.Render(this);

public void WriteAt(string text, Position position, TextStyle? style = null) { }

public void SetCursorVisibility(bool visible) { }

public void SetCursorPosition(Position position) { }

public Task<InputResult> ReadInputAsync() => StartRead(CancellationToken.None);

public Task<InputResult> ReadInputAsync(CancellationToken cancellationToken) =>
StartRead(honoursCancellation ? cancellationToken : CancellationToken.None);

public Task WaitForReadsStartedAsync(int count)
{
lock (gate)
{
if (started >= count)
{
return Task.CompletedTask;
}

TaskCompletionSource signal = new(TaskCreationOptions.RunContinuationsAsynchronously);
startedWaiters.Add((count, signal));
return signal.Task;
}
}

/// <summary>
/// Completes once a read is waiting that was started, or carried over, after any earlier
/// one was given up — that is, once a key pressed now would be read.
/// </summary>
public Task WaitForPendingReadAsync()
{
lock (gate)
{
if (pending.Exists(read => !read.Task.IsCompleted))
{
return Task.CompletedTask;
}

if (pendingWaiter.Task.IsCompleted)
{
pendingWaiter = new(TaskCreationOptions.RunContinuationsAsynchronously);
}

return pendingWaiter.Task;
}
}

public void Press(ConsoleKey key)
{
TaskCompletionSource<InputResult>? read;
lock (gate)
{
read = pending.Find(candidate => !candidate.Task.IsCompleted);
}

read?.TrySetResult(InputResult.FromKey(key));
}

private Task<InputResult> StartRead(CancellationToken cancellationToken)
{
TaskCompletionSource<InputResult> read = new(TaskCreationOptions.RunContinuationsAsynchronously);
cancellationToken.Register(() => read.TrySetCanceled(cancellationToken));

lock (gate)
{
pending.Add(read);
started++;
foreach ((int count, TaskCompletionSource signal) in startedWaiters.Where(waiter => started >= waiter.Count))
{
signal.TrySetResult();
}

pendingWaiter.TrySetResult();
}

return read.Task;
}
}
}
13 changes: 13 additions & 0 deletions TUI/Contracts/IConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ public interface IConsoleProvider
/// <returns>The input result</returns>
public Task<InputResult> ReadInputAsync();

/// <summary>
/// Reads input from the console, giving up once <paramref name="cancellationToken"/> is cancelled
/// </summary>
/// <param name="cancellationToken">Cancelled when the input is no longer wanted, such as when the
/// application shuts down</param>
/// <returns>The input result, or a cancelled task if the read was given up</returns>
/// <remarks>
/// A read that outlives the application takes the next key meant for the host or for a later
/// run, so a provider should end the read when the token is cancelled (ktsu-dev/TUI#149). The
/// default ignores the token and calls <see cref="ReadInputAsync()"/>.
/// </remarks>
public Task<InputResult> ReadInputAsync(CancellationToken cancellationToken) => ReadInputAsync();

/// <summary>
/// Sets the cursor visibility
/// </summary>
Expand Down
45 changes: 44 additions & 1 deletion TUI/Services/SpectreConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public class SpectreConsoleProvider(IAnsiConsole? console = null) : IConsoleProv
{
private readonly IAnsiConsole _console = console ?? AnsiConsole.Console;
private readonly Func<ConsoleKeyInfo> _readKey = () => Console.ReadKey(true);
private readonly Func<bool> _keyAvailable = () => Console.KeyAvailable;

/// <summary>
/// How often a cancellable read checks for a key. Short enough that typing does not feel
/// delayed, long enough that waiting for a key costs next to nothing.
/// </summary>
internal static readonly TimeSpan KeyPollInterval = TimeSpan.FromMilliseconds(15);

/// <summary>
/// Initializes a new instance of the <see cref="SpectreConsoleProvider"/> class that reads keys
Expand All @@ -26,7 +33,24 @@ public class SpectreConsoleProvider(IAnsiConsole? console = null) : IConsoleProv
/// <param name="console">The Spectre.Console instance to use</param>
/// <param name="readKey">Reads the next key.</param>
internal SpectreConsoleProvider(IAnsiConsole? console, Func<ConsoleKeyInfo> readKey)
: this(console) => _readKey = readKey;
: this(console, readKey, keyAvailable: () => true)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="SpectreConsoleProvider"/> class that reads keys
/// from <paramref name="readKey"/> and asks <paramref name="keyAvailable"/> whether one is
/// waiting, instead of using <see cref="Console"/>, so tests can feed it input.
/// </summary>
/// <param name="console">The Spectre.Console instance to use</param>
/// <param name="readKey">Reads the next key.</param>
/// <param name="keyAvailable">Reports whether a key is waiting to be read.</param>
internal SpectreConsoleProvider(IAnsiConsole? console, Func<ConsoleKeyInfo> readKey, Func<bool> keyAvailable)
: this(console)
{
_readKey = readKey;
_keyAvailable = keyAvailable;
}

/// <inheritdoc />
public Dimensions Dimensions => new(_console.Profile.Width, _console.Profile.Height);
Expand Down Expand Up @@ -91,6 +115,25 @@ public void WriteAt(string text, Position position, TextStyle? style = null)
public async Task<InputResult> ReadInputAsync() =>
await Task.Run(() => ToInputResult(_readKey())).ConfigureAwait(false);

/// <inheritdoc />
/// <remarks>
/// <see cref="Console.ReadKey(bool)"/> cannot be interrupted, so this waits for
/// <see cref="Console.KeyAvailable"/> and reads only once a key is there. A cancelled read
/// therefore leaves no thread blocked in ReadKey to take the next key (ktsu-dev/TUI#149).
/// </remarks>
public Task<InputResult> ReadInputAsync(CancellationToken cancellationToken) =>
Task.Run(
async () =>
{
while (!_keyAvailable())
{
await Task.Delay(KeyPollInterval, cancellationToken).ConfigureAwait(false);
}

return ToInputResult(_readKey());
},
cancellationToken);

/// <summary>
/// Converts a key read from the console into an input result.
/// </summary>
Expand Down
Loading
Loading