Skip to content

Commit 3441fb9

Browse files
rayw000Linkgoron
authored andcommitted
readline: add feature yank and yank pop
1. `Ctrl-Y` to yank previously deleted text 2. `Meta-Y` to do yank pop (cycle among deleted texts) 3. Use `getCursorPos().rows` to check if we have reached a new line, instead of `getCursorPos().cols === 0`. 4. document and unittests. PR-URL: nodejs#41301 Fixes: nodejs#41252 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Qingyu Deng <[email protected]>
1 parent afd02d0 commit 3441fb9

File tree

3 files changed

+155
-1
lines changed

3 files changed

+155
-1
lines changed

doc/api/readline.md

+10
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,16 @@ const { createInterface } = require('readline');
13131313
<td>Delete from the current position to the end of line</td>
13141314
<td></td>
13151315
</tr>
1316+
<tr>
1317+
<td><kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
1318+
<td>Yank (Recall) the previously deleted text</td>
1319+
<td>Only works with text deleted by <kbd>Ctrl</kbd>+<kbd>U</kbd> or <kbd>Ctrl</kbd>+<kbd>K</kbd></td>
1320+
</tr>
1321+
<tr>
1322+
<td><kbd>Meta</kbd>+<kbd>Y</kbd></td>
1323+
<td>Cycle among previously deleted lines</td>
1324+
<td>Only available when the last keystroke is <kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
1325+
</tr>
13161326
<tr>
13171327
<td><kbd>Ctrl</kbd>+<kbd>A</kbd></td>
13181328
<td>Go to start of line</td>

lib/internal/readline/interface.js

+74-1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const kQuestionCancel = Symbol('kQuestionCancel');
8181
// GNU readline library - keyseq-timeout is 500ms (default)
8282
const ESCAPE_CODE_TIMEOUT = 500;
8383

84+
// Max length of the kill ring
85+
const kMaxLengthOfKillRing = 32;
86+
8487
const kAddHistory = Symbol('_addHistory');
8588
const kBeforeEdit = Symbol('_beforeEdit');
8689
const kDecoder = Symbol('_decoder');
@@ -96,12 +99,15 @@ const kHistoryPrev = Symbol('_historyPrev');
9699
const kInsertString = Symbol('_insertString');
97100
const kLine = Symbol('_line');
98101
const kLine_buffer = Symbol('_line_buffer');
102+
const kKillRing = Symbol('_killRing');
103+
const kKillRingCursor = Symbol('_killRingCursor');
99104
const kMoveCursor = Symbol('_moveCursor');
100105
const kNormalWrite = Symbol('_normalWrite');
101106
const kOldPrompt = Symbol('_oldPrompt');
102107
const kOnLine = Symbol('_onLine');
103108
const kPreviousKey = Symbol('_previousKey');
104109
const kPrompt = Symbol('_prompt');
110+
const kPushToKillRing = Symbol('_pushToKillRing');
105111
const kPushToUndoStack = Symbol('_pushToUndoStack');
106112
const kQuestionCallback = Symbol('_questionCallback');
107113
const kRedo = Symbol('_redo');
@@ -118,6 +124,9 @@ const kUndoStack = Symbol('_undoStack');
118124
const kWordLeft = Symbol('_wordLeft');
119125
const kWordRight = Symbol('_wordRight');
120126
const kWriteToOutput = Symbol('_writeToOutput');
127+
const kYank = Symbol('_yank');
128+
const kYanking = Symbol('_yanking');
129+
const kYankPop = Symbol('_yankPop');
121130

122131
function InterfaceConstructor(input, output, completer, terminal) {
123132
this[kSawReturnAt] = 0;
@@ -211,6 +220,15 @@ function InterfaceConstructor(input, output, completer, terminal) {
211220
this[kRedoStack] = [];
212221
this.history = history;
213222
this.historySize = historySize;
223+
224+
// The kill ring is a global list of blocks of text that were previously
225+
// killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
226+
// element will be removed to make room for the latest deletion. With kill
227+
// ring, users are able to recall (yank) or cycle (yank pop) among previously
228+
// killed texts, quite similar to the behavior of Emacs.
229+
this[kKillRing] = [];
230+
this[kKillRingCursor] = 0;
231+
214232
this.removeHistoryDuplicates = !!removeHistoryDuplicates;
215233
this.crlfDelay = crlfDelay ?
216234
MathMax(kMincrlfDelay, crlfDelay) :
@@ -606,10 +624,12 @@ class Interface extends InterfaceConstructor {
606624
this.cursor += c.length;
607625
this[kRefreshLine]();
608626
} else {
627+
const oldPos = this.getCursorPos();
609628
this.line += c;
610629
this.cursor += c.length;
630+
const newPos = this.getCursorPos();
611631

612-
if (this.getCursorPos().cols === 0) {
632+
if (oldPos.rows < newPos.rows) {
613633
this[kRefreshLine]();
614634
} else {
615635
this[kWriteToOutput](c);
@@ -792,17 +812,57 @@ class Interface extends InterfaceConstructor {
792812

793813
[kDeleteLineLeft]() {
794814
this[kBeforeEdit](this.line, this.cursor);
815+
const del = StringPrototypeSlice(this.line, 0, this.cursor);
795816
this.line = StringPrototypeSlice(this.line, this.cursor);
796817
this.cursor = 0;
818+
this[kPushToKillRing](del);
797819
this[kRefreshLine]();
798820
}
799821

800822
[kDeleteLineRight]() {
801823
this[kBeforeEdit](this.line, this.cursor);
824+
const del = StringPrototypeSlice(this.line, this.cursor);
802825
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
826+
this[kPushToKillRing](del);
803827
this[kRefreshLine]();
804828
}
805829

830+
[kPushToKillRing](del) {
831+
if (!del || del === this[kKillRing][0]) return;
832+
ArrayPrototypeUnshift(this[kKillRing], del);
833+
this[kKillRingCursor] = 0;
834+
while (this[kKillRing].length > kMaxLengthOfKillRing)
835+
ArrayPrototypePop(this[kKillRing]);
836+
}
837+
838+
[kYank]() {
839+
if (this[kKillRing].length > 0) {
840+
this[kYanking] = true;
841+
this[kInsertString](this[kKillRing][this[kKillRingCursor]]);
842+
}
843+
}
844+
845+
[kYankPop]() {
846+
if (!this[kYanking]) {
847+
return;
848+
}
849+
if (this[kKillRing].length > 1) {
850+
const lastYank = this[kKillRing][this[kKillRingCursor]];
851+
this[kKillRingCursor]++;
852+
if (this[kKillRingCursor] >= this[kKillRing].length) {
853+
this[kKillRingCursor] = 0;
854+
}
855+
const currentYank = this[kKillRing][this[kKillRingCursor]];
856+
const head =
857+
StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
858+
const tail =
859+
StringPrototypeSlice(this.line, this.cursor);
860+
this.line = head + currentYank + tail;
861+
this.cursor = head.length + currentYank.length;
862+
this[kRefreshLine]();
863+
}
864+
}
865+
806866
clearLine() {
807867
this[kMoveCursor](+Infinity);
808868
this[kWriteToOutput]('\r\n');
@@ -984,6 +1044,11 @@ class Interface extends InterfaceConstructor {
9841044
key = key || {};
9851045
this[kPreviousKey] = key;
9861046

1047+
if (!key.meta || key.name !== 'y') {
1048+
// Reset yanking state unless we are doing yank pop.
1049+
this[kYanking] = false;
1050+
}
1051+
9871052
// Activate or deactivate substring search.
9881053
if (
9891054
(key.name === 'up' || key.name === 'down') &&
@@ -1094,6 +1159,10 @@ class Interface extends InterfaceConstructor {
10941159
this[kHistoryPrev]();
10951160
break;
10961161

1162+
case 'y': // Yank killed string
1163+
this[kYank]();
1164+
break;
1165+
10971166
case 'z':
10981167
if (process.platform === 'win32') break;
10991168
if (this.listenerCount('SIGTSTP') > 0) {
@@ -1158,6 +1227,10 @@ class Interface extends InterfaceConstructor {
11581227
case 'backspace': // Delete backwards to a word boundary
11591228
this[kDeleteWordLeft]();
11601229
break;
1230+
1231+
case 'y': // Doing yank pop
1232+
this[kYankPop]();
1233+
break;
11611234
}
11621235
} else {
11631236
/* No modifier keys used */

test/parallel/test-readline-interface.js

+71
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,77 @@ function assertCursorRowsAndCols(rli, rows, cols) {
674674
rli.close();
675675
}
676676

677+
// yank
678+
{
679+
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
680+
fi.emit('data', 'the quick brown fox');
681+
assertCursorRowsAndCols(rli, 0, 19);
682+
683+
// Go to the start of the line
684+
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
685+
// Move forward one char
686+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
687+
// Delete the right part
688+
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
689+
assertCursorRowsAndCols(rli, 0, 1);
690+
691+
// Yank
692+
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
693+
assertCursorRowsAndCols(rli, 0, 19);
694+
695+
rli.on('line', common.mustCall((line) => {
696+
assert.strictEqual(line, 'the quick brown fox');
697+
}));
698+
699+
fi.emit('data', '\n');
700+
rli.close();
701+
}
702+
703+
// yank pop
704+
{
705+
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
706+
fi.emit('data', 'the quick brown fox');
707+
assertCursorRowsAndCols(rli, 0, 19);
708+
709+
// Go to the start of the line
710+
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
711+
// Move forward one char
712+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
713+
// Delete the right part
714+
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
715+
assertCursorRowsAndCols(rli, 0, 1);
716+
// Yank
717+
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
718+
assertCursorRowsAndCols(rli, 0, 19);
719+
720+
// Go to the start of the line
721+
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
722+
// Move forward four chars
723+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
724+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
725+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
726+
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
727+
// Delete the right part
728+
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
729+
assertCursorRowsAndCols(rli, 0, 4);
730+
// Go to the start of the line
731+
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
732+
assertCursorRowsAndCols(rli, 0, 0);
733+
734+
// Yank: 'quick brown fox|the '
735+
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
736+
// Yank pop: 'he quick brown fox|the'
737+
fi.emit('keypress', '.', { meta: true, name: 'y' });
738+
assertCursorRowsAndCols(rli, 0, 18);
739+
740+
rli.on('line', common.mustCall((line) => {
741+
assert.strictEqual(line, 'he quick brown foxthe ');
742+
}));
743+
744+
fi.emit('data', '\n');
745+
rli.close();
746+
}
747+
677748
// Close readline interface
678749
{
679750
const [rli, fi] = getInterface({ terminal: true, prompt: '' });

0 commit comments

Comments
 (0)