Skip to content

Commit

Permalink
readline: add feature yank and yank pop
Browse files Browse the repository at this point in the history
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 <jasnell@gmail.com>
Reviewed-By: Qingyu Deng <i@ayase-lab.com>
  • Loading branch information
rayw000 authored and Linkgoron committed Jan 31, 2022
1 parent afd02d0 commit 3441fb9
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 1 deletion.
10 changes: 10 additions & 0 deletions doc/api/readline.md
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,16 @@ const { createInterface } = require('readline');
<td>Delete from the current position to the end of line</td>
<td></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
<td>Yank (Recall) the previously deleted text</td>
<td>Only works with text deleted by <kbd>Ctrl</kbd>+<kbd>U</kbd> or <kbd>Ctrl</kbd>+<kbd>K</kbd></td>
</tr>
<tr>
<td><kbd>Meta</kbd>+<kbd>Y</kbd></td>
<td>Cycle among previously deleted lines</td>
<td>Only available when the last keystroke is <kbd>Ctrl</kbd>+<kbd>Y</kbd></td>
</tr>
<tr>
<td><kbd>Ctrl</kbd>+<kbd>A</kbd></td>
<td>Go to start of line</td>
Expand Down
75 changes: 74 additions & 1 deletion lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ const kQuestionCancel = Symbol('kQuestionCancel');
// GNU readline library - keyseq-timeout is 500ms (default)
const ESCAPE_CODE_TIMEOUT = 500;

// Max length of the kill ring
const kMaxLengthOfKillRing = 32;

const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
const kDecoder = Symbol('_decoder');
Expand All @@ -96,12 +99,15 @@ const kHistoryPrev = Symbol('_historyPrev');
const kInsertString = Symbol('_insertString');
const kLine = Symbol('_line');
const kLine_buffer = Symbol('_line_buffer');
const kKillRing = Symbol('_killRing');
const kKillRingCursor = Symbol('_killRingCursor');
const kMoveCursor = Symbol('_moveCursor');
const kNormalWrite = Symbol('_normalWrite');
const kOldPrompt = Symbol('_oldPrompt');
const kOnLine = Symbol('_onLine');
const kPreviousKey = Symbol('_previousKey');
const kPrompt = Symbol('_prompt');
const kPushToKillRing = Symbol('_pushToKillRing');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kRedo = Symbol('_redo');
Expand All @@ -118,6 +124,9 @@ const kUndoStack = Symbol('_undoStack');
const kWordLeft = Symbol('_wordLeft');
const kWordRight = Symbol('_wordRight');
const kWriteToOutput = Symbol('_writeToOutput');
const kYank = Symbol('_yank');
const kYanking = Symbol('_yanking');
const kYankPop = Symbol('_yankPop');

function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
Expand Down Expand Up @@ -211,6 +220,15 @@ function InterfaceConstructor(input, output, completer, terminal) {
this[kRedoStack] = [];
this.history = history;
this.historySize = historySize;

// The kill ring is a global list of blocks of text that were previously
// killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
// element will be removed to make room for the latest deletion. With kill
// ring, users are able to recall (yank) or cycle (yank pop) among previously
// killed texts, quite similar to the behavior of Emacs.
this[kKillRing] = [];
this[kKillRingCursor] = 0;

this.removeHistoryDuplicates = !!removeHistoryDuplicates;
this.crlfDelay = crlfDelay ?
MathMax(kMincrlfDelay, crlfDelay) :
Expand Down Expand Up @@ -606,10 +624,12 @@ class Interface extends InterfaceConstructor {
this.cursor += c.length;
this[kRefreshLine]();
} else {
const oldPos = this.getCursorPos();
this.line += c;
this.cursor += c.length;
const newPos = this.getCursorPos();

if (this.getCursorPos().cols === 0) {
if (oldPos.rows < newPos.rows) {
this[kRefreshLine]();
} else {
this[kWriteToOutput](c);
Expand Down Expand Up @@ -792,17 +812,57 @@ class Interface extends InterfaceConstructor {

[kDeleteLineLeft]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, 0, this.cursor);
this.line = StringPrototypeSlice(this.line, this.cursor);
this.cursor = 0;
this[kPushToKillRing](del);
this[kRefreshLine]();
}

[kDeleteLineRight]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, this.cursor);
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
this[kPushToKillRing](del);
this[kRefreshLine]();
}

[kPushToKillRing](del) {
if (!del || del === this[kKillRing][0]) return;
ArrayPrototypeUnshift(this[kKillRing], del);
this[kKillRingCursor] = 0;
while (this[kKillRing].length > kMaxLengthOfKillRing)
ArrayPrototypePop(this[kKillRing]);
}

[kYank]() {
if (this[kKillRing].length > 0) {
this[kYanking] = true;
this[kInsertString](this[kKillRing][this[kKillRingCursor]]);
}
}

[kYankPop]() {
if (!this[kYanking]) {
return;
}
if (this[kKillRing].length > 1) {
const lastYank = this[kKillRing][this[kKillRingCursor]];
this[kKillRingCursor]++;
if (this[kKillRingCursor] >= this[kKillRing].length) {
this[kKillRingCursor] = 0;
}
const currentYank = this[kKillRing][this[kKillRingCursor]];
const head =
StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
const tail =
StringPrototypeSlice(this.line, this.cursor);
this.line = head + currentYank + tail;
this.cursor = head.length + currentYank.length;
this[kRefreshLine]();
}
}

clearLine() {
this[kMoveCursor](+Infinity);
this[kWriteToOutput]('\r\n');
Expand Down Expand Up @@ -984,6 +1044,11 @@ class Interface extends InterfaceConstructor {
key = key || {};
this[kPreviousKey] = key;

if (!key.meta || key.name !== 'y') {
// Reset yanking state unless we are doing yank pop.
this[kYanking] = false;
}

// Activate or deactivate substring search.
if (
(key.name === 'up' || key.name === 'down') &&
Expand Down Expand Up @@ -1094,6 +1159,10 @@ class Interface extends InterfaceConstructor {
this[kHistoryPrev]();
break;

case 'y': // Yank killed string
this[kYank]();
break;

case 'z':
if (process.platform === 'win32') break;
if (this.listenerCount('SIGTSTP') > 0) {
Expand Down Expand Up @@ -1158,6 +1227,10 @@ class Interface extends InterfaceConstructor {
case 'backspace': // Delete backwards to a word boundary
this[kDeleteWordLeft]();
break;

case 'y': // Doing yank pop
this[kYankPop]();
break;
}
} else {
/* No modifier keys used */
Expand Down
71 changes: 71 additions & 0 deletions test/parallel/test-readline-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,77 @@ function assertCursorRowsAndCols(rli, rows, cols) {
rli.close();
}

// yank
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
fi.emit('data', 'the quick brown fox');
assertCursorRowsAndCols(rli, 0, 19);

// Go to the start of the line
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
// Move forward one char
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
// Delete the right part
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
assertCursorRowsAndCols(rli, 0, 1);

// Yank
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
assertCursorRowsAndCols(rli, 0, 19);

rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'the quick brown fox');
}));

fi.emit('data', '\n');
rli.close();
}

// yank pop
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
fi.emit('data', 'the quick brown fox');
assertCursorRowsAndCols(rli, 0, 19);

// Go to the start of the line
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
// Move forward one char
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
// Delete the right part
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
assertCursorRowsAndCols(rli, 0, 1);
// Yank
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
assertCursorRowsAndCols(rli, 0, 19);

// Go to the start of the line
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
// Move forward four chars
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
fi.emit('keypress', '.', { ctrl: true, name: 'f' });
// Delete the right part
fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
assertCursorRowsAndCols(rli, 0, 4);
// Go to the start of the line
fi.emit('keypress', '.', { ctrl: true, name: 'a' });
assertCursorRowsAndCols(rli, 0, 0);

// Yank: 'quick brown fox|the '
fi.emit('keypress', '.', { ctrl: true, name: 'y' });
// Yank pop: 'he quick brown fox|the'
fi.emit('keypress', '.', { meta: true, name: 'y' });
assertCursorRowsAndCols(rli, 0, 18);

rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'he quick brown foxthe ');
}));

fi.emit('data', '\n');
rli.close();
}

// Close readline interface
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });
Expand Down

0 comments on commit 3441fb9

Please sign in to comment.