Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/PrettyPrompt/KeyBindings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
118 changes: 70 additions & 48 deletions src/PrettyPrompt/Panes/CodePane.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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):
{
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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];
Expand All @@ -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];
Expand Down
41 changes: 24 additions & 17 deletions src/PrettyPrompt/Panes/CompletionPane.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/PrettyPrompt/Panes/OverloadPane.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using PrettyPrompt.Highlighting;
using PrettyPrompt.Rendering;
using static System.ConsoleKey;
using static System.ConsoleModifiers;

namespace PrettyPrompt.Panes;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions tests/PrettyPrompt.Tests/CompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
22 changes: 22 additions & 0 deletions tests/PrettyPrompt.Tests/HistoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading