From 22152daae4cbd54323784f802badcaddd36c6f2e Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sun, 5 Jul 2026 20:28:05 +0900 Subject: [PATCH] Handle Ctrl+V and Shift+Insert paste from the clipboard Since v2.3.3, enabling TreatControlCAsInput disables the legacy Windows console's own Ctrl+V paste handling, so the key arrives as a raw ^V input record and prompts just beep. Bind Ctrl+V and Shift+Insert in FormBase to read the clipboard (Win32 CF_UNICODETEXT) and route each character through HandleTextInput, so text input, password input, and select filtering all accept pasted text. Control characters in the pasted text are stripped. The clipboard access is exposed as PromptConfiguration.ClipboardTextProvider so it can be replaced (or stubbed in tests). On non-Windows platforms the default provider returns null and paste continues to be handled by the terminal emulator itself. Fixes #237 Co-Authored-By: Claude Fable 5 --- src/Sharprompt/Forms/FormBase.cs | 28 ++++++- src/Sharprompt/Internal/ClipboardHelper.cs | 54 ++++++++++++ src/Sharprompt/Internal/NativeMethods.cs | 21 +++++ src/Sharprompt/PromptConfiguration.cs | 13 +++ .../Forms/FormInteractionTests.cs | 82 +++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/Sharprompt/Internal/ClipboardHelper.cs diff --git a/src/Sharprompt/Forms/FormBase.cs b/src/Sharprompt/Forms/FormBase.cs index f47892c0..7299d20d 100644 --- a/src/Sharprompt/Forms/FormBase.cs +++ b/src/Sharprompt/Forms/FormBase.cs @@ -22,7 +22,9 @@ protected FormBase(PromptConfiguration configuration) KeyHandlerMaps = new() { - [new ConsoleKeyBinding(ConsoleKey.Escape)] = HandleEscape + [new ConsoleKeyBinding(ConsoleKey.Escape)] = HandleEscape, + [new ConsoleKeyBinding(ConsoleKey.V, ConsoleModifiers.Control)] = HandlePaste, + [new ConsoleKeyBinding(ConsoleKey.Insert, ConsoleModifiers.Shift)] = HandlePaste }; } @@ -149,4 +151,28 @@ private bool HandleEscape() return true; } + + private bool HandlePaste() + { + // With TreatControlCAsInput enabled, the legacy Windows console no longer + // handles Ctrl+V itself, so the key arrives here as a raw input record. + var text = _configuration.ClipboardTextProvider(); + + if (string.IsNullOrEmpty(text)) + { + return false; + } + + var handled = false; + + foreach (var c in text) + { + if (!char.IsControl(c)) + { + handled |= HandleTextInput(new ConsoleKeyInfo(c, default, false, false, false)); + } + } + + return handled; + } } diff --git a/src/Sharprompt/Internal/ClipboardHelper.cs b/src/Sharprompt/Internal/ClipboardHelper.cs new file mode 100644 index 00000000..03582062 --- /dev/null +++ b/src/Sharprompt/Internal/ClipboardHelper.cs @@ -0,0 +1,54 @@ +using System; +using System.Runtime.InteropServices; + +namespace Sharprompt.Internal; + +internal static class ClipboardHelper +{ + public static string? GetText() + { + // Only Windows needs to read the clipboard directly: enabling + // TreatControlCAsInput disables the legacy console's own Ctrl+V paste + // handling, so the key arrives as a raw ^V input record. Other platforms + // rely on the terminal emulator to translate paste into key input. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return null; + } + + if (!NativeMethods.IsClipboardFormatAvailable(NativeMethods.CF_UNICODETEXT) || !NativeMethods.OpenClipboard(IntPtr.Zero)) + { + return null; + } + + try + { + var hMem = NativeMethods.GetClipboardData(NativeMethods.CF_UNICODETEXT); + + if (hMem == IntPtr.Zero) + { + return null; + } + + var pointer = NativeMethods.GlobalLock(hMem); + + if (pointer == IntPtr.Zero) + { + return null; + } + + try + { + return Marshal.PtrToStringUni(pointer); + } + finally + { + NativeMethods.GlobalUnlock(hMem); + } + } + finally + { + NativeMethods.CloseClipboard(); + } + } +} diff --git a/src/Sharprompt/Internal/NativeMethods.cs b/src/Sharprompt/Internal/NativeMethods.cs index e0c22175..16738900 100644 --- a/src/Sharprompt/Internal/NativeMethods.cs +++ b/src/Sharprompt/Internal/NativeMethods.cs @@ -11,6 +11,9 @@ internal static class NativeMethods // ReSharper disable once InconsistentNaming public const int ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; + // ReSharper disable once InconsistentNaming + public const uint CF_UNICODETEXT = 13; + [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle); @@ -19,4 +22,22 @@ internal static class NativeMethods [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr hConsoleHandle, int dwMode); + + [DllImport("user32.dll")] + public static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll")] + public static extern bool OpenClipboard(IntPtr hWndNewOwner); + + [DllImport("user32.dll")] + public static extern IntPtr GetClipboardData(uint uFormat); + + [DllImport("user32.dll")] + public static extern bool CloseClipboard(); + + [DllImport("kernel32.dll")] + public static extern IntPtr GlobalLock(IntPtr hMem); + + [DllImport("kernel32.dll")] + public static extern bool GlobalUnlock(IntPtr hMem); } diff --git a/src/Sharprompt/PromptConfiguration.cs b/src/Sharprompt/PromptConfiguration.cs index aef59cac..9846eb5a 100644 --- a/src/Sharprompt/PromptConfiguration.cs +++ b/src/Sharprompt/PromptConfiguration.cs @@ -1,6 +1,7 @@ using System; using Sharprompt.Drivers; +using Sharprompt.Internal; namespace Sharprompt; @@ -20,6 +21,18 @@ public Func ConsoleDriverFactory } } + private Func _clipboardTextProvider = ClipboardHelper.GetText; + + public Func ClipboardTextProvider + { + get => _clipboardTextProvider; + set + { + ArgumentNullException.ThrowIfNull(value); + _clipboardTextProvider = value; + } + } + public PromptColorSchema ColorSchema { get; } = new(); public PromptSymbols Symbols { get; } = new(); diff --git a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs index 5e390736..e8a58e81 100644 --- a/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs +++ b/tests/Sharprompt.Tests/Forms/FormInteractionTests.cs @@ -199,6 +199,88 @@ public void ConfirmForm_EmptyInputWithDefaultValue_ReturnsDefaultValue() Assert.True(form.Start()); } + [Fact] + public void InputForm_CtrlV_PastesClipboardText() + { + var (driver, configuration) = CreateTestContext(); + + configuration.ClipboardTextProvider = () => "pasted"; + + driver.EnqueueKey(ConsoleKey.V, ConsoleModifiers.Control, '\x16'); + driver.EnqueueEnter(); + + var options = new InputOptions + { + Message = "message" + }; + + using var form = new InputForm(options, configuration); + + Assert.Equal("pasted", form.Start()); + } + + [Fact] + public void InputForm_Paste_StripsControlCharacters() + { + var (driver, configuration) = CreateTestContext(); + + configuration.ClipboardTextProvider = () => "ab\r\ncd"; + + driver.EnqueueKey(ConsoleKey.V, ConsoleModifiers.Control, '\x16'); + driver.EnqueueEnter(); + + var options = new InputOptions + { + Message = "message" + }; + + using var form = new InputForm(options, configuration); + + Assert.Equal("abcd", form.Start()); + } + + [Fact] + public void InputForm_PasteWithEmptyClipboard_KeepsAcceptingInput() + { + var (driver, configuration) = CreateTestContext(); + + configuration.ClipboardTextProvider = () => null; + + driver.EnqueueKey(ConsoleKey.V, ConsoleModifiers.Control, '\x16'); + driver.EnqueueText("ok"); + driver.EnqueueEnter(); + + var options = new InputOptions + { + Message = "message" + }; + + using var form = new InputForm(options, configuration); + + Assert.Equal("ok", form.Start()); + } + + [Fact] + public void SelectForm_ShiftInsert_PastesIntoFilter() + { + var (driver, configuration) = CreateTestContext(); + + configuration.ClipboardTextProvider = () => "che"; + + driver.EnqueueKey(ConsoleKey.Insert, ConsoleModifiers.Shift); + driver.EnqueueEnter(); + + var options = new SelectOptions + { + Message = "message", + Items = ["apple", "banana", "cherry"] + }; + + using var form = new SelectForm(options, configuration); + + Assert.Equal("cherry", form.Start()); + } + [Fact] public void Escape_ThrowsPromptCanceledException() {