diff --git a/src/PrettyPrompt/KeyBindings.cs b/src/PrettyPrompt/KeyBindings.cs index d4e7f76..0fdaa0d 100644 --- a/src/PrettyPrompt/KeyBindings.cs +++ b/src/PrettyPrompt/KeyBindings.cs @@ -34,8 +34,8 @@ public KeyBindings( TriggerCompletionList = Get(triggerCompletionList, new KeyPressPattern(Control, Spacebar)); NewLine = Get(newLine, new KeyPressPattern(Shift, Enter), new KeyPressPattern(Alt, Enter)); SubmitPrompt = Get(submitPrompt, new(Enter), new(Control, Enter), new(Control | Alt, Enter)); - HistoryPrevious = Get(historyPrevious, new KeyPressPattern(UpArrow)); - HistoryNext = Get(historyNext, new KeyPressPattern(DownArrow)); + HistoryPrevious = Get(historyPrevious, new KeyPressPattern(UpArrow), new KeyPressPattern(Control, P)); // Ctrl+P is the emacs binding + HistoryNext = Get(historyNext, new KeyPressPattern(DownArrow), new KeyPressPattern(Control, N)); // Ctrl+N is the emacs binding TriggerOverloadList = triggerOverloadList; static KeyPressPatterns Get(KeyPressPatterns patterns, params KeyPressPattern[] defaultPatterns) diff --git a/src/PrettyPrompt/Panes/CodePane.cs b/src/PrettyPrompt/Panes/CodePane.cs index c705af4..0f5ba53 100644 --- a/src/PrettyPrompt/Panes/CodePane.cs +++ b/src/PrettyPrompt/Panes/CodePane.cs @@ -201,48 +201,52 @@ public async Task OnKeyDown(KeyPress key, CancellationToken cancellationToken) case (Control, End) or (Control | Shift, End): Document.Caret = Document.Length; break; - case (Shift, LeftArrow): - case LeftArrow: + case (Shift, LeftArrow) or LeftArrow or (Control, B): // Ctrl+B = backward one char (emacs) Document.Caret = Document.CalculateCaretIndexToLeft(); break; - case (Shift, RightArrow): - case RightArrow: + case (Shift, RightArrow) or RightArrow or (Control, F): // Ctrl+F = forward one char (emacs) Document.Caret = Document.CalculateCaretIndexToRight(); break; - case (Control | Shift, LeftArrow): - case (Control, LeftArrow): + case (Control | Shift, LeftArrow) or (Control, LeftArrow) or (Alt, B): // Alt+b = backward one word (emacs) Document.MoveToWordBoundary(-1); break; - case (Control | Shift, RightArrow): - case (Control, RightArrow): + case (Control | Shift, RightArrow) or (Control, RightArrow) or (Alt, F): // Alt+f = forward one word (emacs) Document.MoveToWordBoundary(+1); break; - case (Control, Backspace) when selection is null: - var startDeleteIndex = Document.CalculateWordBoundaryIndexNearCaret(-1); - Document.Remove(this, startDeleteIndex, Document.Caret - startDeleteIndex); - break; - case (Control, Delete) when selection is null: - var endDeleteIndex = Document.CalculateWordBoundaryIndexNearCaret(+1); - Document.Remove(this, Document.Caret, endDeleteIndex - Document.Caret); - break; + case (Control, Backspace) + or (Control, H) // Ctrl+H = delete word backward, an alias of Ctrl+Backspace. Ideally this would be 'delete one character' but on Unix/macOS .NET reports the Ctrl+H byte (0x08) as (Control, Backspace). https://github.com/dotnet/runtime/issues/73379#issuecomment-1206168139 + or (Alt, Backspace) // Alt+Backspace = delete word backward (emacs) + when selection is null: + { + var startDeleteIndex = Document.CalculateWordBoundaryIndexNearCaret(-1); + Document.Remove(this, startDeleteIndex, Document.Caret - startDeleteIndex); + break; + } + case (Control, Delete) + or (Alt, Delete) // Alt+Delete = delete word forward (Mac Option+forward-delete) + or (Alt, D) // Alt+d = delete word forward (emacs) + when selection is null: + { + var endDeleteIndex = Document.CalculateWordBoundaryIndexNearCaret(+1); + Document.Remove(this, Document.Caret, endDeleteIndex - Document.Caret); + break; + } case Backspace when selection is null: { // delete the whole grapheme cluster to the left, not a single UTF-16 code unit var clusterStart = Document.CalculateCaretIndexToLeft(); Document.Remove(this, clusterStart, Document.Caret - clusterStart); + break; } - break; - case Delete when selection is null: + case (Delete or (Control, D)) when selection is null: // Ctrl+D = delete one char forwards (emacs) { // delete the whole grapheme cluster to the right, not a single UTF-16 code unit var clusterEnd = Document.CalculateCaretIndexToRight(); Document.Remove(this, Document.Caret, clusterEnd - Document.Caret); + break; } - break; - case (_, Delete) or (_, Backspace) or Delete or Backspace when selection.HasValue: - { - Document.DeleteSelectedText(this); - } + case (_, Delete) or (_, Backspace) or Delete or Backspace or (Control, D) or (Alt, D) or (Control, H) or (Control, K) or (Control, U) when selection.HasValue: + Document.DeleteSelectedText(this); break; case Tab or (Shift, Tab): { @@ -284,6 +288,20 @@ public async Task OnKeyDown(KeyPress key, CancellationToken cancellationToken) await clipboard.SetTextAsync(Document.GetText(span).ToString(), cancellationToken).ConfigureAwait(false); } + Document.Remove(this, span); + break; + } + case (Control, K) when selection is null: // Ctrl+K = delete from caret to end of the current line (emacs/readline kill-line); deletes only, does not write the clipboard + { + var lineEnd = Document.CalculateLineBoundaryIndexNearCaret(+1, smartHome: false); + var span = TextSpan.FromBounds(Document.Caret, lineEnd); + Document.Remove(this, span); + break; + } + case (Control, U) when selection is null: // Ctrl+U = delete from start of the current line to caret (readline/bash unix-line-discard; in emacs Ctrl+U is universal-argument); deletes only, does not write the clipboard + { + var lineStart = Document.CalculateLineBoundaryIndexNearCaret(-1, smartHome: false); + var span = TextSpan.FromBounds(lineStart, Document.Caret); Document.Remove(this, span); break; } @@ -299,36 +317,42 @@ public async Task OnKeyDown(KeyPress key, CancellationToken cancellationToken) case (Shift, Insert) when key.PastedText is not null: PasteText(key.PastedText); break; - case (Control, V): - case (Control | Shift, V): - case (Shift, Insert): - var clipboardText = await clipboard.GetTextAsync(cancellationToken).ConfigureAwait(false); - PasteText(clipboardText); - break; + case (Control, V) or (Control | Shift, V) or (Shift, Insert): + { + var clipboardText = await clipboard.GetTextAsync(cancellationToken).ConfigureAwait(false); + PasteText(clipboardText); + break; + } case (Control, Z): - Document.Undo(out var newSelection); - Selection = newSelection; - if (newSelection.HasValue) { - completionPane.IsOpen = false; - overloadPane.IsOpen = false; + Document.Undo(out var undoSelection); + Selection = undoSelection; + if (undoSelection.HasValue) + { + completionPane.IsOpen = false; + overloadPane.IsOpen = false; + } + break; } - break; case (Control, Y): - Document.Redo(out newSelection); - Selection = newSelection; - if (newSelection.HasValue) { - completionPane.IsOpen = false; - overloadPane.IsOpen = false; + Document.Redo(out var redoSelection); + Selection = redoSelection; + if (redoSelection.HasValue) + { + completionPane.IsOpen = false; + overloadPane.IsOpen = false; + } + break; } - break; default: - if (!(char.IsControl(key.ConsoleKeyInfo.KeyChar) || promptCallbacks.TryGetKeyPressCallbacks(key.ConsoleKeyInfo, out _))) { - Document.InsertAtCaret(this, key.ConsoleKeyInfo.KeyChar); + if (!(char.IsControl(key.ConsoleKeyInfo.KeyChar) || promptCallbacks.TryGetKeyPressCallbacks(key.ConsoleKeyInfo, out _))) + { + Document.InsertAtCaret(this, key.ConsoleKeyInfo.KeyChar); + } + break; } - break; } } @@ -449,8 +473,7 @@ public async Task OnKeyUp(KeyPress key, CancellationToken cancellationToken) switch (key.ObjectPattern) { - case (Shift, UpArrow) when Cursor.Row > 0: - case UpArrow when Cursor.Row > 0: + case (Shift, UpArrow) or UpArrow or (Control, P) when Cursor.Row > 0: // Ctrl+P = previous line (emacs) { var newCursor = Cursor.MoveUp(); var aboveLine = WordWrappedLines[newCursor.Row]; @@ -459,8 +482,7 @@ public async Task OnKeyUp(KeyPress key, CancellationToken cancellationToken) key.Handled = true; break; } - case (Shift, DownArrow) when Cursor.Row < WordWrappedLines.Count - 1: - case DownArrow when Cursor.Row < WordWrappedLines.Count - 1: + case (Shift, DownArrow) or DownArrow or (Control, N) when Cursor.Row < WordWrappedLines.Count - 1: // Ctrl+N = next line (emacs) { var newCursor = Cursor.MoveDown(); var belowLine = WordWrappedLines[newCursor.Row]; diff --git a/src/PrettyPrompt/Panes/CompletionPane.cs b/src/PrettyPrompt/Panes/CompletionPane.cs index 7440506..d30eeed 100644 --- a/src/PrettyPrompt/Panes/CompletionPane.cs +++ b/src/PrettyPrompt/Panes/CompletionPane.cs @@ -96,28 +96,35 @@ async Task IKeyPressHandler.OnKeyDown(KeyPress key, CancellationToken cancellati switch (key.ObjectPattern) { - case Home or (_, Home): - case End or (_, End): - case (Shift, LeftArrow or RightArrow or UpArrow or DownArrow or Home or End) or + case Home or (_, Home) or + End or (_, End) or + (Shift, LeftArrow or RightArrow or UpArrow or DownArrow or Home or End) or (Control | Shift, LeftArrow or RightArrow or UpArrow or DownArrow or Home or End) or (Control, A): - await Close(cancellationToken).ConfigureAwait(false); - return; - case LeftArrow or RightArrow: - // mirror the document's grapheme-cluster-aware caret movement (see CodePane arrow handling) - int caretNew = key.ConsoleKeyInfo.Key == LeftArrow - ? codePane.Document.CalculateCaretIndexToLeft() - : codePane.Document.CalculateCaretIndexToRight(); - if (caretNew < spanToReplace.Start || caretNew > spanToReplace.Start + spanToReplace.Length) { await Close(cancellationToken).ConfigureAwait(false); return; } - break; + case LeftArrow or RightArrow or (Control, B) or (Control, F): // Ctrl+B/Ctrl+F are emacs char movement + { + // mirror the document's grapheme-cluster-aware caret movement (see CodePane arrow handling) + bool movingLeft = key.ObjectPattern is LeftArrow or (Control, B); + int caretNew = movingLeft + ? codePane.Document.CalculateCaretIndexToLeft() + : codePane.Document.CalculateCaretIndexToRight(); + if (caretNew < spanToReplace.Start || caretNew > spanToReplace.Start + spanToReplace.Length) + { + await Close(cancellationToken).ConfigureAwait(false); + return; + } + break; + } case Escape: - await Close(cancellationToken).ConfigureAwait(false); - key.Handled = true; - return; + { + await Close(cancellationToken).ConfigureAwait(false); + key.Handled = true; + return; + } default: break; } @@ -150,11 +157,11 @@ async Task IKeyPressHandler.OnKeyDown(KeyPress key, CancellationToken cancellati //completion list is open and there are some items switch (key.ObjectPattern) { - case DownArrow: + case DownArrow or (Control, N): // Ctrl+N = next item (emacs) await FilteredView.IncrementSelectedIndex(cancellationToken).ConfigureAwait(false); key.Handled = true; break; - case UpArrow: + case UpArrow or (Control, P): // Ctrl+P = previous item (emacs) await FilteredView.DecrementSelectedIndex(cancellationToken).ConfigureAwait(false); key.Handled = true; break; diff --git a/src/PrettyPrompt/Panes/OverloadPane.cs b/src/PrettyPrompt/Panes/OverloadPane.cs index 254e8bc..e9497e7 100644 --- a/src/PrettyPrompt/Panes/OverloadPane.cs +++ b/src/PrettyPrompt/Panes/OverloadPane.cs @@ -16,6 +16,7 @@ using PrettyPrompt.Highlighting; using PrettyPrompt.Rendering; using static System.ConsoleKey; +using static System.ConsoleModifiers; namespace PrettyPrompt.Panes; @@ -194,8 +195,7 @@ Task IKeyPressHandler.OnKeyDown(KeyPress key, CancellationToken cancellationToke case Escape: Close(); break; - case DownArrow: - case UpArrow: + case DownArrow or UpArrow or (Control, N) or (Control, P): // Ctrl+N/Ctrl+P are emacs aliases patternToProcessInKeyUp = key.ObjectPattern; key.Handled = true; break; @@ -225,11 +225,11 @@ async Task IKeyPressHandler.OnKeyUp(KeyPress key, CancellationToken cancellation { switch (key.ObjectPattern) { - case DownArrow: + case DownArrow or (Control, N): // Ctrl+N = next overload (emacs) ++SelectedItemIndex; key.Handled = true; break; - case UpArrow: + case UpArrow or (Control, P): // Ctrl+P = previous overload (emacs) --SelectedItemIndex; key.Handled = true; break; diff --git a/tests/PrettyPrompt.Tests/CompletionTests.cs b/tests/PrettyPrompt.Tests/CompletionTests.cs index cb1a5e9..7f181fa 100644 --- a/tests/PrettyPrompt.Tests/CompletionTests.cs +++ b/tests/PrettyPrompt.Tests/CompletionTests.cs @@ -47,6 +47,22 @@ public async Task ReadLine_MultipleCompletion() Assert.Equal("Aardvark Zebra Alligator", result.Text); } + [Fact] + public async Task ReadLine_MultipleCompletion_NavigatesMenuWithCtrlNAndCtrlP() + { + var console = ConsoleStub.NewConsole(); + // identical to ReadLine_MultipleCompletion, but navigate the open menu with Ctrl+N / Ctrl+P + // (emacs aliases for the arrow keys) instead of Down/Up. + console.StubInput($"Aa{Enter} Z{Tab} Alli{Backspace}{Backspace}{Control}{N}{Control}{P}{Control}{N}{Enter}{Enter}"); + + var prompt = ConfigurePrompt(console); + + var result = await prompt.ReadLineAsync(); + + Assert.True(result.IsSuccess); + Assert.Equal("Aardvark Zebra Alligator", result.Text); + } + [Fact] public async Task ReadLine_MultilineCompletion() { diff --git a/tests/PrettyPrompt.Tests/HistoryTests.cs b/tests/PrettyPrompt.Tests/HistoryTests.cs index 50c13e5..6f0d069 100644 --- a/tests/PrettyPrompt.Tests/HistoryTests.cs +++ b/tests/PrettyPrompt.Tests/HistoryTests.cs @@ -59,6 +59,28 @@ public async Task ReadLine_WithHistory_CyclesThroughHistory() Assert.Equal("", result.Text); } + [Fact] + public async Task ReadLine_WithHistory_CyclesWithCtrlPAndCtrlN() + { + // Ctrl+P / Ctrl+N are emacs aliases for the Up/Down history bindings + var console = ConsoleStub.NewConsole(); + var prompt = new Prompt(console: console); + + console.StubInput($"Hello World{Enter}"); + await prompt.ReadLineAsync(); + + console.StubInput($"Howdy World{Enter}"); + await prompt.ReadLineAsync(); + + console.StubInput($"How ya' doin world{Enter}"); + await prompt.ReadLineAsync(); + + // Ctrl+P three times walks back to the oldest entry; Ctrl+N steps forward one. + console.StubInput($"{Control}{P}{Control}{P}{Control}{P}{Control}{N}{Enter}"); + var result = await prompt.ReadLineAsync(); + Assert.Equal("Howdy World", result.Text); + } + [Fact] public async Task ReadLine_WithHistory_DoNotSaveEmptyInput() { diff --git a/tests/PrettyPrompt.Tests/PromptTests.cs b/tests/PrettyPrompt.Tests/PromptTests.cs index c954716..4f172ff 100644 --- a/tests/PrettyPrompt.Tests/PromptTests.cs +++ b/tests/PrettyPrompt.Tests/PromptTests.cs @@ -207,6 +207,193 @@ public async Task ReadLine_DeleteWordPrevWordKeys() Assert.Equal($"aaaa bbbb eeee ffff{NewLine}", result.Text); } + [Fact] + public async Task ReadLine_EmacsCharNavigation_CtrlBCtrlF() + { + // Ctrl+B / Ctrl+F are emacs aliases for Left/Right arrow + var console = ConsoleStub.NewConsole(); + console.StubInput($"abcd{Control}{B}{Control}{B}X{Control}{F}Y{Enter}"); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // "abcd": Ctrl+B twice puts the caret between 'b' and 'c' -> type X -> "abXcd"; + // Ctrl+F moves past 'c' -> type Y -> "abXcYd". + Assert.Equal("abXcYd", result.Text); + } + + [Fact] + public async Task ReadLine_EmacsLineNavigation_CtrlPCtrlN() + { + // Ctrl+P / Ctrl+N are emacs aliases for Up/Down arrow + var console = ConsoleStub.NewConsole(); + console.StubInput( + $"aaa{Shift}{Enter}", + $"bbb{Shift}{Enter}", + $"ccc", + $"{Control}{P}{Control}{P}{Home}1", + $"{Control}{N}{End}2", + $"{Enter}" + ); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // From line 3, Ctrl+P twice reaches line 1 (Home, type 1 -> "1aaa"); + // Ctrl+N moves down to line 2 (End, type 2 -> "bbb2"). + Assert.Equal($"1aaa{NewLine}bbb2{NewLine}ccc", result.Text); + } + + [Fact] + public async Task ReadLine_EmacsForwardDelete_CtrlD() + { + // Ctrl+D is the emacs alias for Delete + var console = ConsoleStub.NewConsole(); + console.StubInput($"abcd{Home}{Control}{D}{Control}{D}{Enter}"); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // Home moves to the start; Ctrl+D deletes 'a' then 'b'. + Assert.Equal("cd", result.Text); + } + + [Fact] + public async Task ReadLine_DeleteToEndOfLine_CtrlK() + { + // Ctrl+K (emacs/readline kill-line) deletes from the caret to the end of the current line, not past the newline. + // Even though it's technically a "cut" in emacs/readline terminology, that's to the kill ring, + // so as a design choice we decided not to touch the system clipboard. + var console = ConsoleStub.NewConsole(); + using (console.ProtectClipboard()) + { + console.Clipboard.SetText(""); + console.StubInput( + $"aaXbb{Shift}{Enter}", + $"ccc", + $"{Control}{P}{Home}{RightArrow}{RightArrow}{Control}{K}{Enter}" + ); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // Caret after "aa" on line 1; Ctrl+K deletes "Xbb" (to end of line, leaving the newline intact). + Assert.Equal($"aa{NewLine}ccc", result.Text); + Assert.Equal("", console.Clipboard.GetText()); // clipboard left untouched + } + } + + [Fact] + public async Task ReadLine_DeleteToStartOfLine_CtrlU() + { + // Ctrl+U (readline/bash unix-line-discard; in emacs Ctrl+U is universal-argument) deletes from + // the start of the current line to the caret. Even though it's technically a "cut" in readline + // terminology, that's to the kill ring, so as a design choice we decided not to touch the system clipboard. + var console = ConsoleStub.NewConsole(); + using (console.ProtectClipboard()) + { + console.Clipboard.SetText(""); + console.StubInput($"hello world{Home}{RightArrow}{RightArrow}{RightArrow}{RightArrow}{RightArrow}{Control}{U}{Enter}"); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // Caret after "hello"; Ctrl+U deletes "hello", leaving " world". + Assert.Equal(" world", result.Text); + Assert.Equal("", console.Clipboard.GetText()); // clipboard left untouched + } + } + + [Fact] + public async Task ReadLine_LineKill_WithSelection_DeletesSelection() + { + // With an active selection, Ctrl+K / Ctrl+U delete the selection (like Backspace/Delete/Ctrl+D), + // rather than killing to the line boundary or silently dropping the selection. + var console = ConsoleStub.NewConsole(); + var prompt = new Prompt(console: console); + + // select "bc", then Ctrl+K deletes the selection -> "ad" + console.StubInput($"abcd{Home}{RightArrow}{Shift}{RightArrow}{Shift}{RightArrow}{Control}{K}{Enter}"); + var result = await prompt.ReadLineAsync(); + Assert.True(result.IsSuccess); + Assert.Equal("ad", result.Text); + + // select "bc", then Ctrl+U deletes the selection -> "ad" + console.StubInput($"abcd{Home}{RightArrow}{Shift}{RightArrow}{Shift}{RightArrow}{Control}{U}{Enter}"); + result = await prompt.ReadLineAsync(); + Assert.True(result.IsSuccess); + Assert.Equal("ad", result.Text); + } + + [Fact] + public async Task ReadLine_EmacsWordNavigation_AltFAltB() + { + // Alt+f / Alt+b are emacs aliases for Ctrl+RightArrow / Ctrl+LeftArrow (word motion). + // Mirrors ReadLine_NextWordPrevWordKeys with the Alt keys so the same edits produce the same text. + var console = ConsoleStub.NewConsole(); + console.StubInput( + $"aaaa bbbb 5555{Shift}{Enter}", + $"dddd x5x5 foo.bar{Shift}{Enter}", + $"{UpArrow}{Alt}{F}{Alt}{F}{Alt}{F}{Alt}{F}lum", + $"{Alt}{B}{Alt}{B}{Alt}{B}{Backspace}{Tab}", + $"{Enter}" + ); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + Assert.Equal($"aaaa bbbb 5555{NewLine}dddd x5x5{DefaultTabSpaces}foo.lumbar{NewLine}", result.Text); + } + + [Fact] + public async Task ReadLine_EmacsDeleteWord_AltDAltBackspace() + { + // Alt+d / Alt+Backspace are emacs aliases for Ctrl+Delete / Ctrl+Backspace (word delete). + // Mirrors ReadLine_DeleteWordPrevWordKeys with the Alt keys. + var console = ConsoleStub.NewConsole(); + console.StubInput( + $"aaaa bbbb cccc{Shift}{Enter}", + $"dddd eeee ffff{Shift}{Enter}", + $"{UpArrow}{Alt}{D}{Alt}{Backspace}", + $"{Enter}" + ); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + Assert.Equal($"aaaa bbbb eeee ffff{NewLine}", result.Text); + } + + [Fact] + public async Task ReadLine_DeleteWordForward_AltDelete() + { + // Alt+Delete = delete word forward (Mac Option+forward-delete), an alias of Ctrl+Delete. + var console = ConsoleStub.NewConsole(); + console.StubInput($"foo bar baz{Home}{Alt}{Delete}{Enter}"); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // Home moves to start; Alt+Delete deletes the first word forward (including its trailing space). + Assert.Equal("bar baz", result.Text); + } + + [Fact] + public async Task ReadLine_CtrlH_DeletesWordBackward() + { + // Ctrl+H is bound to delete-word-backward, a true alias of Ctrl+Backspace (issue #277). + // On macOS the Ctrl+H byte (0x08) is reported by .NET as (Control, Backspace) anyway, so it + // lands on the same action; on Windows it arrives as (Control, H). Both resolve to delete-word. + var console = ConsoleStub.NewConsole(); + console.StubInput($"foo bar baz{Control}{H}{Enter}"); + + var prompt = new Prompt(console: console); + var result = await prompt.ReadLineAsync(); + + // Ctrl+H deletes the last word "baz". + Assert.Equal("foo bar ", result.Text); + } + [Fact] public async Task ReadLine_TypeReallyQuickly_DoesNotDropKeyPresses() {