From 15579d2f929956d75a0a79329b4be321e8ea5331 Mon Sep 17 00:00:00 2001 From: Maxime Labelle Date: Wed, 7 Nov 2018 14:35:00 +0100 Subject: [PATCH] New - #333 Supports linewise yanks. --- PSReadLine/BasicEditing.cs | 2 +- PSReadLine/Position.cs | 3 +- PSReadLine/ReadLine.vi.cs | 8 +- PSReadLine/StringBuilderLinewiseExtensions.cs | 61 +++++ PSReadLine/UndoRedo.cs | 42 +++- PSReadLine/ViRegister.cs | 232 ++++++++++++++++++ PSReadLine/YankPaste.vi.cs | 82 +++++-- test/StringBuilderLinewiseExtensionsTests.cs | 54 ++++ test/ViRegisterTests.cs | 140 +++++++++++ test/YankPasteTest.VI.cs | 23 +- 10 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 PSReadLine/StringBuilderLinewiseExtensions.cs create mode 100644 PSReadLine/ViRegister.cs create mode 100644 test/StringBuilderLinewiseExtensionsTests.cs create mode 100644 test/ViRegisterTests.cs diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs index 21a26a508..7ab74e78e 100644 --- a/PSReadLine/BasicEditing.cs +++ b/PSReadLine/BasicEditing.cs @@ -121,7 +121,7 @@ public static void BackwardDeleteLine(ConsoleKeyInfo? key = null, object arg = n { if (_singleton._current > 0) { - _singleton._clipboard = _singleton._buffer.ToString(0, _singleton._current); + _singleton._clipboard.Record(_singleton._buffer, 0, _singleton._current); _singleton.SaveEditItem(EditItemDelete.Create(_singleton._clipboard, 0)); _singleton._buffer.Remove(0, _singleton._current); _singleton._current = 0; diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs index 4beb03259..3a8585445 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -39,8 +39,7 @@ private static int GetBeginningOfLinePos(int current) /// Returns the position of the end of the logical line /// as specified by the "current" position. /// - /// - /// + /// private static int GetEndOfLogicalLinePos(int current) { var newCurrent = current; diff --git a/PSReadLine/ReadLine.vi.cs b/PSReadLine/ReadLine.vi.cs index 78c2ec641..05e0dbf30 100644 --- a/PSReadLine/ReadLine.vi.cs +++ b/PSReadLine/ReadLine.vi.cs @@ -221,7 +221,7 @@ public static void DeleteToEnd(ConsoleKeyInfo? key = null, object arg = null) return; } - _singleton._clipboard = _singleton._buffer.ToString(_singleton._current, _singleton._buffer.Length - _singleton._current); + _singleton._clipboard.Record(_singleton._buffer, _singleton._current, _singleton._buffer.Length - _singleton._current); _singleton.SaveEditItem(EditItemDelete.Create( _singleton._clipboard, _singleton._current, @@ -723,7 +723,7 @@ public static void DeleteLineToFirstChar(ConsoleKeyInfo? key = null, object arg /// public static void DeleteLine(ConsoleKeyInfo? key = null, object arg = null) { - _singleton._clipboard = _singleton._buffer.ToString(); + _singleton._clipboard.Record(_singleton._buffer); _singleton.SaveEditItem(EditItemDelete.Create(_singleton._clipboard, 0)); _singleton._current = 0; _singleton._buffer.Remove(0, _singleton._buffer.Length); @@ -746,7 +746,7 @@ public static void BackwardDeleteWord(ConsoleKeyInfo? key = null, object arg = n Ding(); return; } - _singleton._clipboard = _singleton._buffer.ToString(deletePoint, _singleton._current - deletePoint); + _singleton._clipboard.Record(_singleton._buffer, deletePoint, _singleton._current - deletePoint); _singleton.SaveEditItem(EditItemDelete.Create( _singleton._clipboard, deletePoint, @@ -779,7 +779,7 @@ public static void ViBackwardDeleteGlob(ConsoleKeyInfo? key = null, object arg = Ding(); return; } - _singleton._clipboard = _singleton._buffer.ToString(deletePoint, _singleton._current - deletePoint); + _singleton._clipboard.Record(_singleton._buffer, deletePoint, _singleton._current - deletePoint); _singleton.SaveEditItem(EditItemDelete.Create( _singleton._clipboard, deletePoint, diff --git a/PSReadLine/StringBuilderLinewiseExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs new file mode 100644 index 000000000..2e141e1a8 --- /dev/null +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -0,0 +1,61 @@ +using System; +using System.Text; + +namespace Microsoft.PowerShell +{ + public class Range + { + public int Offset { get; set; } + public int Count { get; set; } + } + + public static class StringBuilderLinewiseExtensions + { + /// + /// Determines the offset and the length of the fragment + /// in the specified buffer that corresponds to a + /// given number of lines starting from the specified line index + /// + /// + /// + /// + public static Range GetRange(this StringBuilder buffer, int lineIndex, int lineCount) + { + var length = buffer.Length; + + var startPosition = 0; + var startPositionIdentified = false; + + var endPosition = length - 1; + var endPositionIdentified = false; + + var currentLine = 0; + + for (var position = 0; position < length; position++) + { + if (currentLine == lineIndex && !startPositionIdentified) + { + startPosition = position; + startPositionIdentified = true; + } + + if (buffer[position] == '\n') + { + currentLine++; + } + + if (currentLine == lineIndex + lineCount && !endPositionIdentified) + { + endPosition = position; + endPositionIdentified = true; + } + } + + return new Range + { + Offset = startPosition, + Count = endPosition - startPosition + 1, + }; + } + } +} \ No newline at end of file diff --git a/PSReadLine/UndoRedo.cs b/PSReadLine/UndoRedo.cs index d1d62e890..ed00003a2 100644 --- a/PSReadLine/UndoRedo.cs +++ b/PSReadLine/UndoRedo.cs @@ -148,16 +148,18 @@ class EditItemInsertString : EditItem { // The string inserted tells us the length to delete on undo. // The contents of the string are only needed for redo. - private string _insertedString; - private int _insertStartPosition; + private readonly string _insertedString; + private readonly int _insertStartPosition; + + protected EditItemInsertString(string str, int position) + { + _insertedString = str; + _insertStartPosition = position; + } public static EditItem Create(string str, int position) { - return new EditItemInsertString - { - _insertedString = str, - _insertStartPosition = position - }; + return new EditItemInsertString(str, position); } public override void Undo() @@ -175,6 +177,32 @@ public override void Redo() } } + [DebuggerDisplay("Insert '{_insertedString}' ({_insertStartPosition}, Anchor: {_insertAnchor})")] + class EditItemInsertLines : EditItemInsertString + { + // in linewise pastes, the _insertAnchor represents the position + // of the cursor at the time paste was invoked. This is recorded + // so as to be restored when undoing the paste. + private readonly int _insertAnchor; + + private EditItemInsertLines(string str, int position, int anchor) + :base(str, position) + { + _insertAnchor = anchor; + } + + public static EditItem Create(string str, int position, int anchor) + { + return new EditItemInsertLines(str, position, anchor); + } + + public override void Undo() + { + base.Undo(); + _singleton._current = _insertAnchor; + } + } + [DebuggerDisplay("Delete '{_deletedString}' ({_deleteStartPosition})")] class EditItemDelete : EditItem { diff --git a/PSReadLine/ViRegister.cs b/PSReadLine/ViRegister.cs new file mode 100644 index 000000000..2a26e5964 --- /dev/null +++ b/PSReadLine/ViRegister.cs @@ -0,0 +1,232 @@ +using System; +using System.Text; + +namespace Microsoft.PowerShell +{ + public partial class PSConsoleReadLine + { + /// + /// Used to report when the buffer is about to change + /// as part of a paste operation. + /// + public sealed class PasteEventArgs : EventArgs + { + /// + /// The text being pasted. + /// + public string Text { get; set; } + /// + /// The position in the buffer at + /// which the pasted text will be inserted. + /// + public int Position { get; set; } + /// + /// The recorded position in the buffer + /// from which the paste operation originates. + /// This is usually the same as Position, but + /// not always. For instance, when pasting a + /// linewise selection before the current line, + /// the Anchor is the cursor position, whereas + /// the Position is the beginning of the previous line. + /// + public int Anchor { get; set; } + } + + /// + /// Represents a named register. + /// + public sealed class ViRegister + { + private string _text; + private bool _linewise; + + /// + /// Raised when the text from this register is about + /// to be pasted to the buffer specified as part of + /// a call to Paste[After|Before]. + /// + public event EventHandler OnInserting; + + /// + /// Returns whether this register is empty. + /// + public bool IsEmpty + => String.IsNullOrEmpty(_text); + + /// + /// Returns whether this register contains + /// linewise yanked text. + /// + public bool Linewise + => _linewise; + + public string RawText + => _text; + + /// + /// Records the entire buffer in the register. + /// + /// + public void Record(StringBuilder buffer) + { + Record(buffer, 0, buffer.Length); + } + + /// + /// Records a piece of text in the register. + /// + /// + /// + /// + public void Record(StringBuilder buffer, int offset, int count) + { + System.Diagnostics.Debug.Assert(offset >= 0 && offset < buffer.Length); + System.Diagnostics.Debug.Assert(offset + count <= buffer.Length); + + _linewise = false; + _text = buffer.ToString(offset, count); + } + + /// + /// Records a block of lines in the register. + /// + /// + public void LinewizeRecord(string text) + { + _linewise = true; + _text = text; + } + + // for compatibility reasons, as an interim solution + public static implicit operator string(ViRegister register) + { + return register._text; + } + + public int PasteAfter(StringBuilder buffer, int position) + { + if (IsEmpty) + { + return position; + } + + if (_linewise) + { + var text = _text; + + // paste text after the next line + + var pastePosition = -1; + var newCursorPosition = position; + + for (var index = position; index < buffer.Length; index++) + { + if (buffer[index] == '\n') + { + pastePosition = index + 1; + newCursorPosition = pastePosition; + break; + } + } + + if (pastePosition == -1) + { + if (text[0] != '\n') + { + text = '\n' + text; + } + + pastePosition = buffer.Length; + newCursorPosition = pastePosition + 1; + } + + InsertAt(buffer, text, pastePosition, position); + + return newCursorPosition; + } + + else + { + if (position < buffer.Length) + { + position += 1; + } + + InsertAt(buffer, _text, position, position); + position += _text.Length - 1; + + return position; + } + } + + public int PasteBefore(StringBuilder buffer, int position) + { + if (_linewise) + { + var text = _text; + + if (text[text.Length - 1] != '\n') + { + text += '\n'; + } + + // paste text before the current line + + var previousLinePos = -1; + + for (var index = position; index > 0; index--) + { + if (buffer[index] == '\n') + { + previousLinePos = index + 1; + break; + } + } + + if (previousLinePos == -1) + { + previousLinePos = 0; + } + + InsertBefore(buffer, text, previousLinePos, position); + + return previousLinePos; + } + else + { + InsertAt(buffer, _text, position, position); + return position + _text.Length - 1; + } + } + + private void InsertBefore(StringBuilder buffer, string text, int pastePosition, int position) + { + OnInserting?.Invoke(this, new PasteEventArgs { Text = text, Position = pastePosition, Anchor = position, }); + buffer.Insert(pastePosition, text); + } + + private void InsertAt(StringBuilder buffer, string text, int pastePosition, int position) + { + OnInserting?.Invoke(this, new PasteEventArgs { Text = text, Position = pastePosition, Anchor = position, }); + + // Use Append if possible because Insert at end makes StringBuilder quite slow. + if (pastePosition == buffer.Length) + { + buffer.Append(text); + } + else + { + buffer.Insert(pastePosition, text); + } + } + +#if DEBUG + public override string ToString() + { + var text = _text.Replace("\n", "\\n"); + return (_linewise ? "line: " : "") + "\"" + text + "\""; + } +#endif + } + } +} \ No newline at end of file diff --git a/PSReadLine/YankPaste.vi.cs b/PSReadLine/YankPaste.vi.cs index 0d3a86cf2..272fb6b8b 100644 --- a/PSReadLine/YankPaste.vi.cs +++ b/PSReadLine/YankPaste.vi.cs @@ -3,24 +3,42 @@ --********************************************************************/ using System; +using System.Text; namespace Microsoft.PowerShell { public partial class PSConsoleReadLine { - private string _clipboard = string.Empty; + private readonly ViRegister _clipboard = InitializeClipboard(); + + internal static ViRegister InitializeClipboard() + { + // the register's PasteBefore and PasteAfter + // methods adjust the cursor position in the buffer + // before inserted its content. + + // use this hook to support undo + + var register = new ViRegister(); + register.OnInserting += (sender, e) => + { + var editItem = EditItemInsertLines.Create( + e.Text, + e.Position, + e.Anchor + ); + + _singleton.SaveEditItem(editItem); + }; + + return register; + } /// /// Paste the clipboard after the cursor, moving the cursor to the end of the pasted text. /// public static void PasteAfter(ConsoleKeyInfo? key = null, object arg = null) { - if (string.IsNullOrEmpty(_singleton._clipboard)) - { - Ding(); - return; - } - _singleton.PasteAfterImpl(); } @@ -29,43 +47,63 @@ public static void PasteAfter(ConsoleKeyInfo? key = null, object arg = null) /// public static void PasteBefore(ConsoleKeyInfo? key = null, object arg = null) { - if (string.IsNullOrEmpty(_singleton._clipboard)) - { - Ding(); - return; - } _singleton.PasteBeforeImpl(); } private void PasteAfterImpl() { - if (_current < _buffer.Length) + if (_clipboard.IsEmpty) { - _current++; + Ding(); + return; } - Insert(_clipboard); - _current--; + + _current = _clipboard.PasteAfter(_buffer, _current); Render(); } private void PasteBeforeImpl() - { - Insert(_clipboard); - _current--; + { + if (_clipboard.IsEmpty) + { + Ding(); + return; + } + + if (_clipboard.Linewise) + _current = _clipboard.PasteBefore(_buffer, _current); + else + { + _current = _clipboard.PasteBefore(_buffer, _current); + } Render(); } private void SaveToClipboard(int startIndex, int length) { - _clipboard = _buffer.ToString(startIndex, length); + _clipboard.Record(_buffer, startIndex, length); + } + + /// + /// Saves a number of logical lines in the unnamed register + /// starting at the specified line number and specified count. + /// + /// The logical number of the current line, starting at 0. + /// The number of lines to record to the unnamed register + private void SaveLinesToClipboard(int lineIndex, int lineCount) + { + var range = _buffer.GetRange(lineIndex, lineCount); + _clipboard.LinewizeRecord(_buffer.ToString(range.Offset, range.Count)); } /// /// Yank the entire buffer. /// public static void ViYankLine(ConsoleKeyInfo? key = null, object arg = null) - { - _singleton.SaveToClipboard(0, _singleton._buffer.Length); + { + TryGetArgAsInt(arg, out var lineCount, 1); + var lineIndex = _singleton.GetLogicalLineNumber() - 1; + _singleton.SaveLinesToClipboard(lineIndex, lineCount); } /// diff --git a/test/StringBuilderLinewiseExtensionsTests.cs b/test/StringBuilderLinewiseExtensionsTests.cs new file mode 100644 index 000000000..9423ff1c8 --- /dev/null +++ b/test/StringBuilderLinewiseExtensionsTests.cs @@ -0,0 +1,54 @@ +using System.Text; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public sealed class StringBuilderLinewiseExtensionsTests + { + [Fact] + public void StringBuilderLinewiseExtensions_LinewiseYank_Fragment() + { + var buffer = new StringBuilder("line1\nline2"); + + // system under test + + var range = buffer.GetRange(1, 42); + + // assert + + Assert.Equal(6, range.Offset); + Assert.Equal(5, range.Count); + } + + [Fact] + public void StringBuilderLinewiseExtensions_LinewiseYank_Line() + { + var buffer = new StringBuilder("line1\nline2"); + + // system under test + + var range = buffer.GetRange(1, 42); + + // assert + + Assert.Equal(6, range.Offset); + Assert.Equal(5, range.Count); + } + + [Fact] + public void StringBuilderLinewiseExtensions_LinewiseYank_Lines() + { + var buffer = new StringBuilder("line1\nline2\nline3\nline4"); + + // system under test + + var range = buffer.GetRange(1, 2); + + // assert + + Assert.Equal(6, range.Offset); + Assert.Equal(12, range.Count); + } + } +} \ No newline at end of file diff --git a/test/ViRegisterTests.cs b/test/ViRegisterTests.cs new file mode 100644 index 000000000..36d13ff68 --- /dev/null +++ b/test/ViRegisterTests.cs @@ -0,0 +1,140 @@ +using System.Text; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public sealed class ViRegisterTests + { + #region Linewize paste + + [Fact] + public void ViRegister_Fragment_LinewizePasteBefore() + { + const string yanked = "line1"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line2"); + const int position = 2; + + var newPosition = register.PasteBefore(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(0, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewizePasteBefore() + { + const string yanked = "line1\n"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line2"); + const int position = 2; + + var newPosition = register.PasteBefore(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(0, newPosition); + } + + + [Fact] + public void ViRegister_Fragment_LinewizePasteAfter_Fragment() + { + const string yanked = "line2"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Fragment_LinewizePasteAfter_Lines() + { + const string yanked = "line2"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1\n"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewizePasteAfter_Fragment() + { + const string yanked = "line2\nline3\n"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2\nline3\n", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewizePasteAfter_Lines() + { + const string yanked = "line2\nline3\n"; + + var register = new PSConsoleReadLine.ViRegister(); + register.LinewizeRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1\n"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2\nline3\n", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + #endregion + } +} \ No newline at end of file diff --git a/test/YankPasteTest.VI.cs b/test/YankPasteTest.VI.cs index 0bf02433b..e048afc2f 100644 --- a/test/YankPasteTest.VI.cs +++ b/test/YankPasteTest.VI.cs @@ -209,12 +209,14 @@ public void ViPasteAfterYankLine() { TestSetup(KeyMode.Vi); + var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; + Test("012 456", Keys( "012 456", _.Escape, - "byyP", CheckThat(() => AssertLineIs("012 012 456456")), CheckThat(() => AssertCursorLeftIs(10)), + "byyP", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(0)), "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)), - "p", CheckThat(() => AssertLineIs("012 4012 45656")), CheckThat(() => AssertCursorLeftIs(11)), - "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(5)) + "p", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)) )); } @@ -457,5 +459,20 @@ public void ViPasteAfterYankEndOfGlob() "u" )); } + + [Fact] + public void ViYankAndPasteLogicalLines() + { + TestSetup(KeyMode.Vi); + + Test("\"\nline1\nline2\nline1\nline2\n\"", Keys( + _.DQuote, _.Enter, + "line1", _.Enter, + "line2", _.Enter, + _.DQuote, _.Escape, + _.K, _.K, + "2yy", 'P' + )); + } } }