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;
}
///