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
94 changes: 94 additions & 0 deletions TUI.Test/SpectreConsoleProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,100 @@ public void WriteAtClipsTextOverflowingTheRightEdge()
Assert.AreEqual("\u001b[3;8Habc", output.ToString());
}

/// <summary>
/// Tests that a printable key keeps its typed character as well as its key and modifiers, so
/// Shift+1 can be told apart from 1 (ktsu-dev/TUI#152).
/// </summary>
/// <param name="keyChar">The character the key produced.</param>
/// <param name="key">The key that was pressed.</param>
/// <param name="shift">Whether Shift was held.</param>
[TestMethod]
[DataRow('!', ConsoleKey.D1, true)]
[DataRow('1', ConsoleKey.D1, false)]
[DataRow('A', ConsoleKey.A, true)]
[DataRow('a', ConsoleKey.A, false)]
[DataRow('?', ConsoleKey.Oem2, true)]
[DataRow('é', ConsoleKey.Oem1, false)]
public void ToInputResultKeepsThePrintableCharacter(char keyChar, ConsoleKey key, bool shift)
{
InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo(keyChar, key, shift, alt: false, control: false));

Assert.AreEqual(InputType.Keyboard, result.Type);
Assert.AreEqual(key, result.Key);
Assert.AreEqual(shift ? ConsoleModifiers.Shift : default, result.Modifiers);
Assert.AreEqual(keyChar, result.Character);
}

/// <summary>
/// Tests that a key read from the console arrives with its typed character (ktsu-dev/TUI#152).
/// </summary>
/// <returns>A task that completes when the test has run.</returns>
[TestMethod]
public async Task ReadInputAsyncReturnsTheTypedCharacter()
{
SpectreConsoleProvider provider = new(console: null, () => new ConsoleKeyInfo('!', ConsoleKey.D1, shift: true, alt: false, control: false));

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

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

/// <summary>
/// Tests that a key with no printable character, such as an arrow or Enter, carries no
/// character.
/// </summary>
/// <param name="keyChar">The character the key produced.</param>
/// <param name="key">The key that was pressed.</param>
[TestMethod]
[DataRow('\0', ConsoleKey.UpArrow)]
[DataRow('\r', ConsoleKey.Enter)]
[DataRow('\t', ConsoleKey.Tab)]
[DataRow('\b', ConsoleKey.Backspace)]
public void ToInputResultLeavesTheCharacterUnsetForNonPrintableKeys(char keyChar, ConsoleKey key)
{
InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo(keyChar, key, shift: false, alt: false, control: false));

Assert.AreEqual(InputType.Keyboard, result.Type);
Assert.AreEqual(key, result.Key);
Assert.IsNull(result.Character);
}

/// <summary>
/// Tests that Escape still asks the application to exit.
/// </summary>
[TestMethod]
public void ToInputResultTreatsEscapeAsExit()
{
InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo('\u001b', ConsoleKey.Escape, shift: false, alt: false, control: false));

Assert.IsTrue(result.IsExit);
}

/// <summary>
/// Tests that Ctrl+C asks the application to exit, for a host that has set
/// <see cref="Console.TreatControlCAsInput"/>.
/// </summary>
[TestMethod]
public void ToInputResultTreatsControlCAsExit()
{
InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo('\u0003', ConsoleKey.C, shift: false, alt: false, control: true));

Assert.IsTrue(result.IsExit);
}

/// <summary>
/// Tests that C without Ctrl is ordinary typed input, not an exit request.
/// </summary>
[TestMethod]
public void ToInputResultTreatsPlainCAsACharacter()
{
InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo('c', ConsoleKey.C, shift: false, alt: false, control: false));

Assert.IsFalse(result.IsExit);
Assert.AreEqual('c', result.Character);
}

/// <summary>
/// Tests that showing or hiding the cursor writes the DECTCEM sequence to the injected console
/// and does not move the cursor (ktsu-dev/TUI#153).
Expand Down
51 changes: 35 additions & 16 deletions TUI/Services/SpectreConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ namespace ktsu.TUI.Core.Services;
public class SpectreConsoleProvider(IAnsiConsole? console = null) : IConsoleProvider
{
private readonly IAnsiConsole _console = console ?? AnsiConsole.Console;
private readonly Func<ConsoleKeyInfo> _readKey = () => Console.ReadKey(true);

/// <summary>
/// Initializes a new instance of the <see cref="SpectreConsoleProvider"/> class that reads keys
/// from <paramref name="readKey"/> instead of <see cref="Console.ReadKey(bool)"/>, 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>
internal SpectreConsoleProvider(IAnsiConsole? console, Func<ConsoleKeyInfo> readKey)
: this(console) => _readKey = readKey;

/// <inheritdoc />
public Dimensions Dimensions => new(_console.Profile.Width, _console.Profile.Height);
Expand Down Expand Up @@ -77,24 +88,32 @@ public void WriteAt(string text, Position position, TextStyle? style = null)
}

/// <inheritdoc />
public async Task<InputResult> ReadInputAsync()
public async Task<InputResult> ReadInputAsync() =>
await Task.Run(() => ToInputResult(_readKey())).ConfigureAwait(false);

/// <summary>
/// Converts a key read from the console into an input result.
/// </summary>
/// <param name="keyInfo">The key that was read.</param>
/// <returns>The input result.</returns>
internal static InputResult ToInputResult(ConsoleKeyInfo keyInfo)
{
return await Task.Run(() =>
// Handle special cases. Ctrl+C normally arrives as an interrupt signal rather than as
// a key, and UIApplication handles it there; this branch only fires for a host that
// has set Console.TreatControlCAsInput.
if (keyInfo.Key == ConsoleKey.Escape ||
(keyInfo.Key == ConsoleKey.C && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)))
{
ConsoleKeyInfo keyInfo = Console.ReadKey(true);

// Handle special cases. Ctrl+C normally arrives as an interrupt signal rather than as
// a key, and UIApplication handles it there; this branch only fires for a host that
// has set Console.TreatControlCAsInput.
if (keyInfo.Key == ConsoleKey.Escape ||
(keyInfo.Key == ConsoleKey.C && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)))
{
return InputResult.Exit();
}

// Return keyboard input
return InputResult.FromKey(keyInfo.Key, keyInfo.Modifiers);
}).ConfigureAwait(false);
return InputResult.Exit();
}

// Keep the typed character alongside the key. Only the character tells '!' from '1', and
// only it reflects Caps Lock and the keyboard layout, so text entry needs it
// (ktsu-dev/TUI#152). The key and modifiers stay, so key-based handlers are unaffected.
InputResult result = InputResult.FromKey(keyInfo.Key, keyInfo.Modifiers);
return keyInfo.KeyChar != '\0' && !char.IsControl(keyInfo.KeyChar)
? result with { Character = keyInfo.KeyChar }
: result;
}

/// <inheritdoc />
Expand Down
Loading