diff --git a/lib/internal/console/constructor.js b/lib/internal/console/constructor.js index c3716a8acda8b5..22b84e5e42b956 100644 --- a/lib/internal/console/constructor.js +++ b/lib/internal/console/constructor.js @@ -429,7 +429,10 @@ const consoleMethods = { if (this._stdout.isTTY && process.env.TERM !== 'dumb') { // The require is here intentionally to avoid readline being // required too early when console is first loaded. - const { cursorTo, clearScreenDown } = require('readline'); + const { + cursorTo, + clearScreenDown, + } = require('internal/readline/callbacks'); cursorTo(this._stdout, 0, 0); clearScreenDown(this._stdout); } diff --git a/lib/internal/readline/callbacks.js b/lib/internal/readline/callbacks.js new file mode 100644 index 00000000000000..ae7cf0c07dda0b --- /dev/null +++ b/lib/internal/readline/callbacks.js @@ -0,0 +1,132 @@ +'use strict'; + +const { + NumberIsNaN, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + ERR_INVALID_CURSOR_POS, + }, +} = require('internal/errors'); + +const { + validateCallback, +} = require('internal/validators'); +const { + CSI, +} = require('internal/readline/utils'); + +const { + kClearLine, + kClearScreenDown, + kClearToLineBeginning, + kClearToLineEnd, +} = CSI; + + +/** + * moves the cursor to the x and y coordinate on the given stream + */ + +function cursorTo(stream, x, y, callback) { + if (callback !== undefined) { + validateCallback(callback); + } + + if (typeof y === 'function') { + callback = y; + y = undefined; + } + + if (NumberIsNaN(x)) throw new ERR_INVALID_ARG_VALUE('x', x); + if (NumberIsNaN(y)) throw new ERR_INVALID_ARG_VALUE('y', y); + + if (stream == null || (typeof x !== 'number' && typeof y !== 'number')) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + if (typeof x !== 'number') throw new ERR_INVALID_CURSOR_POS(); + + const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; + return stream.write(data, callback); +} + +/** + * moves the cursor relative to its current location + */ + +function moveCursor(stream, dx, dy, callback) { + if (callback !== undefined) { + validateCallback(callback); + } + + if (stream == null || !(dx || dy)) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + let data = ''; + + if (dx < 0) { + data += CSI`${-dx}D`; + } else if (dx > 0) { + data += CSI`${dx}C`; + } + + if (dy < 0) { + data += CSI`${-dy}A`; + } else if (dy > 0) { + data += CSI`${dy}B`; + } + + return stream.write(data, callback); +} + +/** + * clears the current line the cursor is on: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + */ + +function clearLine(stream, dir, callback) { + if (callback !== undefined) { + validateCallback(callback); + } + + if (stream === null || stream === undefined) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + const type = + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine; + return stream.write(type, callback); +} + +/** + * clears the screen from the current position of the cursor down + */ + +function clearScreenDown(stream, callback) { + if (callback !== undefined) { + validateCallback(callback); + } + + if (stream === null || stream === undefined) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + return stream.write(kClearScreenDown, callback); +} + +module.exports = { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +}; diff --git a/lib/internal/readline/emitKeypressEvents.js b/lib/internal/readline/emitKeypressEvents.js new file mode 100644 index 00000000000000..9c5a2554de9d22 --- /dev/null +++ b/lib/internal/readline/emitKeypressEvents.js @@ -0,0 +1,96 @@ +'use strict'; + +const { + SafeStringIterator, + Symbol, +} = primordials; + +const { + charLengthAt, + CSI, + emitKeys, +} = require('internal/readline/utils'); + +const { clearTimeout, setTimeout } = require('timers'); +const { + kEscape, +} = CSI; + +const { StringDecoder } = require('string_decoder'); + +const KEYPRESS_DECODER = Symbol('keypress-decoder'); +const ESCAPE_DECODER = Symbol('escape-decoder'); + +// GNU readline library - keyseq-timeout is 500ms (default) +const ESCAPE_CODE_TIMEOUT = 500; + +/** + * accepts a readable Stream instance and makes it emit "keypress" events + */ + +function emitKeypressEvents(stream, iface = {}) { + if (stream[KEYPRESS_DECODER]) return; + + stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); + + stream[ESCAPE_DECODER] = emitKeys(stream); + stream[ESCAPE_DECODER].next(); + + const triggerEscape = () => stream[ESCAPE_DECODER].next(''); + const { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface; + let timeoutId; + + function onData(input) { + if (stream.listenerCount('keypress') > 0) { + const string = stream[KEYPRESS_DECODER].write(input); + if (string) { + clearTimeout(timeoutId); + + // This supports characters of length 2. + iface._sawKeyPress = charLengthAt(string, 0) === string.length; + iface.isCompletionEnabled = false; + + let length = 0; + for (const character of new SafeStringIterator(string)) { + length += character.length; + if (length === string.length) { + iface.isCompletionEnabled = true; + } + + try { + stream[ESCAPE_DECODER].next(character); + // Escape letter at the tail position + if (length === string.length && character === kEscape) { + timeoutId = setTimeout(triggerEscape, escapeCodeTimeout); + } + } catch (err) { + // If the generator throws (it could happen in the `keypress` + // event), we need to restart it. + stream[ESCAPE_DECODER] = emitKeys(stream); + stream[ESCAPE_DECODER].next(); + throw err; + } + } + } + } else { + // Nobody's watching anyway + stream.removeListener('data', onData); + stream.on('newListener', onNewListener); + } + } + + function onNewListener(event) { + if (event === 'keypress') { + stream.on('data', onData); + stream.removeListener('newListener', onNewListener); + } + } + + if (stream.listenerCount('keypress') > 0) { + stream.on('data', onData); + } else { + stream.on('newListener', onNewListener); + } +} + +module.exports = emitKeypressEvents; diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index 80c56144051e19..aff7dafe16e95a 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -41,7 +41,7 @@ const { clearScreenDown, cursorTo, moveCursor, -} = require('readline'); +} = require('internal/readline/callbacks'); const { commonPrefix, diff --git a/lib/readline.js b/lib/readline.js index 582fc26baf7a97..3bbf597fcce2e7 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -65,6 +65,14 @@ const { SafeStringIterator, } = primordials; +const { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +} = require('internal/readline/callbacks'); +const emitKeypressEvents = require('internal/readline/emitKeypressEvents') + const { AbortError, codes diff --git a/node.gyp b/node.gyp index 3308ecb432c34f..642a9767fb64d8 100644 --- a/node.gyp +++ b/node.gyp @@ -218,6 +218,8 @@ 'lib/internal/process/signal.js', 'lib/internal/process/task_queues.js', 'lib/internal/querystring.js', + 'lib/internal/readline/callbacks.js', + 'lib/internal/readline/emitKeypressEvents.js', 'lib/internal/readline/utils.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js',