From f9c13b59c90bf897d7e57595d7b1b995283f41b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:36:46 +0000 Subject: [PATCH 1/3] fix: pass the typed character through with each key [patch] SpectreConsoleProvider returned only the key and modifiers from Console.ReadKey, dropping KeyChar, so no element could tell '!' from '1', see Caps Lock, or follow a non-US layout. A printable KeyChar now fills InputResult.Character alongside the key and modifiers, so key-based handlers keep working. The conversion moves into an internal ToInputResult so it can be tested without a console. Fixes ktsu-dev/TUI#152 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XMvgrK1uK13jn4qeVXxDi1 --- TUI.Test/SpectreConsoleProviderTests.cs | 55 +++++++++++++++++++++++++ TUI/Services/SpectreConsoleProvider.cs | 40 +++++++++++------- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index 1631612..0c524a2 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -112,4 +112,59 @@ 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 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); + } } diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index c24546f..b9d78cf 100644 --- a/TUI/Services/SpectreConsoleProvider.cs +++ b/TUI/Services/SpectreConsoleProvider.cs @@ -77,24 +77,32 @@ public void WriteAt(string text, Position position, TextStyle? style = null) } /// - public async Task ReadInputAsync() + public async Task ReadInputAsync() => + await Task.Run(() => ToInputResult(Console.ReadKey(true))).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; } /// From a5d59e9e1b779fedbfd53bbb7e816ff12c02585f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:44:23 +0000 Subject: [PATCH 2/3] test: cover Ctrl+C and plain C in ToInputResult [patch] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XMvgrK1uK13jn4qeVXxDi1 --- TUI.Test/SpectreConsoleProviderTests.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index 0c524a2..bbdcd50 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -167,4 +167,28 @@ public void ToInputResultTreatsEscapeAsExit() 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); + } } From d950f99c8c11f8bdd31659bc4155e23ca88a78e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:49:28 +0000 Subject: [PATCH 3/3] test: drive ReadInputAsync through an injected key reader [patch] ReadInputAsync called Console.ReadKey directly, so no test could reach it. An internal constructor now takes the key reader, and a test checks that a Shift+1 read arrives as '!' end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XMvgrK1uK13jn4qeVXxDi1 --- TUI.Test/SpectreConsoleProviderTests.cs | 15 +++++++++++++++ TUI/Services/SpectreConsoleProvider.cs | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index 481b09a..232feff 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -137,6 +137,21 @@ public void ToInputResultKeepsThePrintableCharacter(char keyChar, ConsoleKey key 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. diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index fc648dc..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); @@ -78,7 +89,7 @@ public void WriteAt(string text, Position position, TextStyle? style = null) /// public async Task ReadInputAsync() => - await Task.Run(() => ToInputResult(Console.ReadKey(true))).ConfigureAwait(false); + await Task.Run(() => ToInputResult(_readKey())).ConfigureAwait(false); /// /// Converts a key read from the console into an input result.