diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index df5aa6c..232feff 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -113,6 +113,100 @@ public void WriteAtClipsTextOverflowingTheRightEdge() Assert.AreEqual("\u001b[3;8Habc", output.ToString()); } + /// + /// 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). + /// + /// The character the key produced. + /// The key that was pressed. + /// Whether Shift was held. + [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); + } + + /// + /// Tests that a key read from the console arrives with its typed character (ktsu-dev/TUI#152). + /// + /// A task that completes when the test has run. + [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); + } + + /// + /// Tests that a key with no printable character, such as an arrow or Enter, carries no + /// character. + /// + /// The character the key produced. + /// The key that was pressed. + [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); + } + + /// + /// Tests that Escape still asks the application to exit. + /// + [TestMethod] + public void ToInputResultTreatsEscapeAsExit() + { + InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo('\u001b', ConsoleKey.Escape, shift: false, alt: false, control: false)); + + Assert.IsTrue(result.IsExit); + } + + /// + /// Tests that Ctrl+C asks the application to exit, for a host that has set + /// . + /// + [TestMethod] + public void ToInputResultTreatsControlCAsExit() + { + InputResult result = SpectreConsoleProvider.ToInputResult(new ConsoleKeyInfo('\u0003', ConsoleKey.C, shift: false, alt: false, control: true)); + + Assert.IsTrue(result.IsExit); + } + + /// + /// Tests that C without Ctrl is ordinary typed input, not an exit request. + /// + [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); + } + /// /// 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). diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index 6cfb12c..90ea4de 100644 --- a/TUI/Services/SpectreConsoleProvider.cs +++ b/TUI/Services/SpectreConsoleProvider.cs @@ -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 _readKey = () => Console.ReadKey(true); + + /// + /// Initializes a new instance of the class that reads keys + /// from instead of , so tests can + /// feed it input. + /// + /// The Spectre.Console instance to use + /// Reads the next key. + internal SpectreConsoleProvider(IAnsiConsole? console, Func readKey) + : this(console) => _readKey = readKey; /// public Dimensions Dimensions => new(_console.Profile.Width, _console.Profile.Height); @@ -77,24 +88,32 @@ public void WriteAt(string text, Position position, TextStyle? style = null) } /// - public async Task ReadInputAsync() + public async Task ReadInputAsync() => + await Task.Run(() => ToInputResult(_readKey())).ConfigureAwait(false); + + /// + /// Converts a key read from the console into an input result. + /// + /// The key that was read. + /// The input result. + 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; } ///