From beafc91348e77e6ca40a9ce8f4efc34a316391b1 Mon Sep 17 00:00:00 2001 From: stdlib-bot Date: Thu, 25 Apr 2024 03:23:41 +0000 Subject: [PATCH] Auto-generated commit --- .gitattributes | 1 + README.md | 1 + lib/auto_close_pairs.js | 70 ++- lib/commands.js | 2 + lib/commands/pager.js | 64 +++ lib/completer_preview.js | 56 +- lib/defaults.js | 3 + lib/display_prompt.js | 4 + lib/main.js | 181 +++++- lib/output_stream.js | 525 ++++++++++++++++++ lib/settings.js | 4 + package.json | 8 +- test/integration/fixtures/repl.js | 11 +- test/integration/test.auto_close_pairs.js | 1 + test/integration/test.auto_delete_pairs.js | 3 +- test/integration/test.auto_page.js | 545 +++++++++++++++++++ test/integration/test.completion_previews.js | 40 +- 17 files changed, 1480 insertions(+), 39 deletions(-) create mode 100644 lib/commands/pager.js create mode 100644 lib/output_stream.js create mode 100644 test/integration/test.auto_page.js diff --git a/.gitattributes b/.gitattributes index eb7e8cb9..1c88e69c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -60,6 +60,7 @@ Makefile linguist-vendored *.mk linguist-vendored *.jl linguist-vendored *.py linguist-vendored +*.R linguist-vendored # Configure files which should be included in GitHub language statistics: docs/types/*.d.ts -linguist-documentation diff --git a/README.md b/README.md index f682299d..0583c5a1 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ The function supports specifying the following settings: - **autoClosePairs**: boolean indicating whether to automatically insert matching brackets, parentheses, and quotes. Default: `true`. - **autoDeletePairs**: boolean indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes. Default: `true`. +- **autoPage**: boolean indicating whether to automatically page return values having a display size exceeding the visible screen. When streams are TTY, the default is `true`; otherwise, the default is `false`. - **completionPreviews**: boolean indicating whether to display completion previews for auto-completion. When streams are TTY, the default is `true`; otherwise, the default is `false`. #### REPL.prototype.createContext() diff --git a/lib/auto_close_pairs.js b/lib/auto_close_pairs.js index a4bf667e..2d8e5cf5 100644 --- a/lib/auto_close_pairs.js +++ b/lib/auto_close_pairs.js @@ -63,15 +63,19 @@ function isQuote( ch ) { * @private * @constructor * @param {Object} rli - readline instance +* @param {boolean} autoClose - boolean indicating whether auto-closing should be initially enabled +* @param {boolean} autoDelete - boolean indicating whether auto-deleting should be initially enabled * @returns {AutoCloser} auto-closer instance */ -function AutoCloser( rli ) { +function AutoCloser( rli, autoClose, autoDelete ) { if ( !(this instanceof AutoCloser) ) { - return new AutoCloser( rli ); + return new AutoCloser( rli, autoClose, autoDelete ); } debug( 'Creating an auto-closer...' ); this._rli = rli; this._ignoreBackspace = false; + this._autoClose = autoClose; + this._autoDelete = autoDelete; return this; } @@ -239,6 +243,62 @@ setNonEnumerableReadOnly( AutoCloser.prototype, '_autodeleteOpenSymbol', functio return true; }); +/** +* Disables auto-closing pairs. +* +* @name disableAutoClose +* @memberof AutoCloser.prototype +* @type {Function} +* @returns {AutoCloser} auto-close instance +*/ +setNonEnumerableReadOnly( AutoCloser.prototype, 'disableAutoClose', function disableAutoClose() { + debug( 'Disabling auto-closing pairs...' ); + this._autoClose = false; + return this; +}); + +/** +* Enables auto-closing pairs. +* +* @name enableAutoClose +* @memberof AutoCloser.prototype +* @type {Function} +* @returns {AutoCloser} auto-close instance +*/ +setNonEnumerableReadOnly( AutoCloser.prototype, 'enableAutoClose', function enableAutoClose() { + debug( 'Enabling auto-closing pairs...' ); + this._autoClose = true; + return this; +}); + +/** +* Disables auto-deleting pairs. +* +* @name disableAutoDelete +* @memberof AutoCloser.prototype +* @type {Function} +* @returns {AutoCloser} auto-close instance +*/ +setNonEnumerableReadOnly( AutoCloser.prototype, 'disableAutoDelete', function disableAutoDelete() { + debug( 'Disabling auto-deleting pairs...' ); + this._autoDelete = false; + return this; +}); + +/** +* Enables auto-deleting pairs. +* +* @name enableAutoDelete +* @memberof AutoCloser.prototype +* @type {Function} +* @returns {AutoCloser} auto-close instance +*/ +setNonEnumerableReadOnly( AutoCloser.prototype, 'enableAutoDelete', function enableAutoDelete() { + debug( 'Enabling auto-deleting pairs...' ); + this._autoDelete = true; + return this; +}); + /** * Callback which should be invoked **before** a "keypress" event is processed by a readline interface. * @@ -253,6 +313,9 @@ setNonEnumerableReadOnly( AutoCloser.prototype, 'beforeKeypress', function befor var cursor; var line; + if ( !this._autoDelete ) { + return false; + } if ( !key || key.name !== 'backspace' ) { return false; } @@ -294,6 +357,9 @@ setNonEnumerableReadOnly( AutoCloser.prototype, 'onKeypress', function onKeypres var cursor; var line; + if ( !this._autoClose ) { + return false; + } cursor = this._rli.cursor; line = this._rli.line; diff --git a/lib/commands.js b/lib/commands.js index e065d0d0..f5ceec95 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -48,6 +48,7 @@ var isKeyword = require( './commands/is_keyword.js' ); var onLicense = require( './commands/license_text.js' ); var onLoad = require( './commands/load.js' ); var onLoadWorkspace = require( './commands/load_workspace.js' ); +var onPager = require( './commands/pager.js' ); var onPresentationStart = require( './commands/presentation_start.js' ); var onPresentationStop = require( './commands/presentation_stop.js' ); var onQuit = require( './commands/quit.js' ); @@ -116,6 +117,7 @@ function commands( repl ) { cmds.push( [ 'license', onLicense( repl ), false ] ); cmds.push( [ 'load', onLoad( repl ), false ] ); cmds.push( [ 'loadWorkspace', onLoadWorkspace( repl ), false ] ); + cmds.push( [ 'pager', onPager( repl ), false ] ); cmds.push( [ 'presentationStart', onPresentationStart( repl ), false ] ); cmds.push( [ 'presentationStop', onPresentationStop( repl ), false ] ); cmds.push( [ 'quit', onQuit( repl ), false ] ); diff --git a/lib/commands/pager.js b/lib/commands/pager.js new file mode 100644 index 00000000..c9ce3ab6 --- /dev/null +++ b/lib/commands/pager.js @@ -0,0 +1,64 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +// MAIN // + +/** +* Returns a callback to be invoked upon calling the `pager` command. +* +* @private +* @param {REPL} repl - REPL instance +* @returns {Function} callback +*/ +function command( repl ) { + return onCommand; + + /** + * Enables paging for a provided string. + * + * @private + * @param {string} value - input string + */ + function onCommand( value ) { + var ostream = repl._ostream; + + // Check whether auto-paging is already enabled... + if ( repl.settings( 'autoPage' ) ) { + // Nothing needed here, as we can defer to already enabled behavior: + ostream.write( value ); + return; + } + // Temporarily enable paging: + ostream.enablePaging(); + + // Write the input value: + ostream.write( value ); + + // Disable paging: + ostream.disablePaging(); + } +} + + +// EXPORTS // + +module.exports = command; diff --git a/lib/completer_preview.js b/lib/completer_preview.js index ac14a6f9..8551e283 100644 --- a/lib/completer_preview.js +++ b/lib/completer_preview.js @@ -44,14 +44,18 @@ var debug = logger( 'repl:completer:preview' ); * @param {Object} rli - readline instance * @param {Function} completer - function for generating possible completions * @param {WritableStream} ostream - writable stream +* @param {boolean} enabled - boolean indicating whether the completer should be initially enabled * @returns {PreviewCompleter} completer instance */ -function PreviewCompleter( rli, completer, ostream ) { +function PreviewCompleter( rli, completer, ostream, enabled ) { if ( !(this instanceof PreviewCompleter) ) { - return new PreviewCompleter( rli, completer, ostream ); + return new PreviewCompleter( rli, completer, ostream, enabled ); } debug( 'Creating a preview completer...' ); + // Initialize a flag indicating whether the preview completer is enabled: + this._enabled = enabled; + // Cache a reference to the provided readline interface: this._rli = rli; @@ -76,6 +80,7 @@ function PreviewCompleter( rli, completer, ostream ) { * @private * @name _completionCallback * @memberof PreviewCompleter.prototype +* @type {Function} * @returns {Function} completion callback */ setNonEnumerableReadOnly( PreviewCompleter.prototype, '_completionCallback', function completionCallback() { @@ -143,12 +148,16 @@ setNonEnumerableReadOnly( PreviewCompleter.prototype, '_completionCallback', fun * * @name clear * @memberof PreviewCompleter.prototype +* @type {Function} * @returns {void} */ setNonEnumerableReadOnly( PreviewCompleter.prototype, 'clear', function clear() { var preview; var N; + if ( !this._enabled ) { + return; + } preview = this._preview; // If no preview currently displayed, nothing to clear... @@ -173,16 +182,51 @@ setNonEnumerableReadOnly( PreviewCompleter.prototype, 'clear', function clear() this._preview = ''; }); +/** +* Disables the preview completer. +* +* @name disable +* @memberof PreviewCompleter.prototype +* @type {Function} +* @returns {PreviewCompleter} completer instance +*/ +setNonEnumerableReadOnly( PreviewCompleter.prototype, 'disable', function disable() { + this.clear(); + + debug( 'Disabling the preview completer...' ); + this._enabled = false; + + return this; +}); + +/** +* Enables the preview completer. +* +* @name enable +* @memberof PreviewCompleter.prototype +* @type {Function} +* @returns {PreviewCompleter} completer instance +*/ +setNonEnumerableReadOnly( PreviewCompleter.prototype, 'enable', function enable() { + debug( 'Enabling the preview completer...' ); + this._enabled = true; + return this; +}); + /** * Callback for handling a "keypress" event. * * @name onKeypress * @memberof PreviewCompleter.prototype +* @type {Function} * @param {string} data - input data * @param {(Object|void)} key - key object * @returns {void} */ setNonEnumerableReadOnly( PreviewCompleter.prototype, 'onKeypress', function onKeypress() { + if ( !this._enabled ) { + return; + } // Check for existing content beyond the cursor which could "collide" with a preview completion... if ( /[^a-zA-Z0-9_$]/.test( this._rli.line.substring( this._rli.cursor ) ) ) { // FIXME: this is not robust (see https://mathiasbynens.be/notes/javascript-identifiers) return; @@ -199,14 +243,22 @@ setNonEnumerableReadOnly( PreviewCompleter.prototype, 'onKeypress', function onK * * @name beforeKeypress * @memberof PreviewCompleter.prototype +* @type {Function} * @param {string} data - input data * @param {(Object|void)} key - key object * @returns {void} */ setNonEnumerableReadOnly( PreviewCompleter.prototype, 'beforeKeypress', function beforeKeypress( data, key ) { + if ( !this._enabled ) { + return; + } if ( !key || this._preview === '' ) { return; } + // Avoid clashing with existing TAB completion behavior... + if ( key.name === 'tab' ) { + return this.clear(); + } // Handle the case where the user is not at the end of the line... if ( this._rli.cursor !== this._rli.line.length ) { // If a user is in the middle of a line and presses ENTER, clear the preview string, as the preview was not accepted prior to executing the expression... diff --git a/lib/defaults.js b/lib/defaults.js index a9ddd5c6..52ea52ee 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -86,6 +86,9 @@ function defaults() { // Flag indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes: 'autoDeletePairs': true, + // Flag indicating whether to enable automatically page return values requiring a display size exceeding the visible screen (note: default depends on whether TTY): + 'autoPage': void 0, + // Flag indicating whether to enable the display of completion previews for auto-completion (note: default depends on whether TTY): 'completionPreviews': void 0 } diff --git a/lib/display_prompt.js b/lib/display_prompt.js index eccb0e09..e4004bba 100644 --- a/lib/display_prompt.js +++ b/lib/display_prompt.js @@ -41,6 +41,10 @@ function displayPrompt( repl, preserveCursor ) { var re; var ws; + // Avoid displaying a prompt if the REPL is currently in paging mode... + if ( repl._ostream.isPaging ) { + return; + } len = repl._cmd.length; if ( len === 0 ) { if ( repl._padding && repl._count >= 0 ) { diff --git a/lib/main.js b/lib/main.js index a9af0d4d..fbde7747 100644 --- a/lib/main.js +++ b/lib/main.js @@ -24,10 +24,12 @@ var EventEmitter = require( 'events' ).EventEmitter; var readline = require( 'readline' ); +var proc = require( 'process' ); var resolve = require( 'path' ).resolve; var logger = require( 'debug' ); var inherit = require( '@stdlib/utils/inherit' ); var isString = require( '@stdlib/assert/is-string' ).isPrimitive; +var isNumber = require( '@stdlib/assert/is-number' ).isPrimitive; var isFunction = require( '@stdlib/assert/is-function' ); var isConfigurableProperty = require( '@stdlib/assert/is-configurable-property' ); var hasOwnProp = require( '@stdlib/assert/has-own-property' ); @@ -56,6 +58,7 @@ var createEvalContext = require( './create_evaluation_context.js' ); var commands = require( './commands.js' ); var displayPrompt = require( './display_prompt.js' ); var inputPrompt = require( './input_prompt.js' ); +var OutputStream = require( './output_stream.js' ); var processLine = require( './process_line.js' ); var completerFactory = require( './completer.js' ); var PreviewCompleter = require( './completer_preview.js' ); @@ -93,6 +96,7 @@ var debug = logger( 'repl' ); * @param {Object} [options.settings] - REPL settings * @param {boolean} [options.settings.autoClosePairs=true] - boolean indicating whether to automatically insert matching brackets, parentheses, and quotes * @param {boolean} [options.settings.autoDeletePairs=true] - boolean indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes +* @param {boolean} [options.settings.autoPage] - boolean indicating whether to automatically page return values requiring a display size exceeding the visible screen * @param {boolean} [options.settings.completionPreviews] - boolean indicating whether to enable completion previews for auto-completion * @throws {Error} must provide valid options * @returns {REPL} REPL instance @@ -116,6 +120,7 @@ var debug = logger( 'repl' ); * repl.close(); */ function REPL( options ) { + var ostream; var opts; var self; var err; @@ -136,6 +141,7 @@ function REPL( options ) { } } opts.isTTY = ( opts.isTTY === void 0 ) ? opts.output.isTTY : opts.isTTY; + opts.settings.autoPage = ( opts.settings.autoPage === void 0 ) ? opts.isTTY : opts.settings.autoPage; // eslint-disable-line max-len opts.settings.completionPreviews = ( opts.settings.completionPreviews === void 0 ) ? opts.isTTY : opts.settings.completionPreviews; // eslint-disable-line max-len debug( 'Options: %s', JSON.stringify({ @@ -158,9 +164,16 @@ function REPL( options ) { // Call the parent constructor: EventEmitter.call( this ); + // Create an internal output stream: + ostream = new OutputStream( this, opts.settings.autoPage ); + + // Setup the output stream pipeline: + ostream.pipe( opts.output ); + // Cache references to the input and output streams: setNonEnumerableReadOnly( this, '_istream', opts.input ); - setNonEnumerableReadOnly( this, '_ostream', opts.output ); + setNonEnumerableReadOnly( this, '_ostream', ostream ); + setNonEnumerableReadOnly( this, '_wstream', opts.output ); // Cache options: setNonEnumerableReadOnly( this, '_inputPrompt', opts.inputPrompt ); @@ -252,10 +265,10 @@ function REPL( options ) { })); // Create a new auto-closer: - setNonEnumerableReadOnly( this, '_autoCloser', new AutoCloser( this._rli ) ); + setNonEnumerableReadOnly( this, '_autoCloser', new AutoCloser( this._rli, this._settings.autoClosePairs, this._settings.autoDeletePairs ) ); // Initialize a preview completer: - setNonEnumerableReadOnly( this, '_previewCompleter', new PreviewCompleter( this._rli, this._completer, this._ostream ) ); + setNonEnumerableReadOnly( this, '_previewCompleter', new PreviewCompleter( this._rli, this._completer, this._ostream, this._settings.completionPreviews ) ); // Cache a reference to the private readline interface `ttyWrite` to allow calling the method when wanting default behavior: setNonEnumerableReadOnly( this, '_ttyWrite', this._rli._ttyWrite ); @@ -267,6 +280,7 @@ function REPL( options ) { this._rli.on( 'close', onClose ); this._rli.on( 'line', onLine ); this._rli.on( 'SIGINT', onSIGINT ); + proc.on( 'SIGWINCH', onSIGWINCH ); // terminal resize // Add listener for "command" events: this.on( 'command', onCommand ); @@ -278,7 +292,7 @@ function REPL( options ) { this._istream.on( 'keypress', onKeypress ); // Write a welcome message: - this._ostream.write( opts.welcome ); + this._wstream.write( opts.welcome ); // TODO: check whether to synchronously initialize a REPL history file @@ -301,13 +315,12 @@ function REPL( options ) { * @param {(Object|void)} key - key object */ function beforeKeypress( data, key ) { - var settings = self._settings; - if ( settings.autoDeletePairs ) { - self._autoCloser.beforeKeypress( data, key ); - } - if ( settings.completionPreviews ) { - self._previewCompleter.beforeKeypress( data, key ); + if ( self._ostream.isPaging ) { + self._ostream.beforeKeypress( data, key ); + return; } + self._autoCloser.beforeKeypress( data, key ); + self._previewCompleter.beforeKeypress( data, key ); self._ttyWrite.call( self._rli, data, key ); } @@ -321,24 +334,16 @@ function REPL( options ) { */ function onKeypress( data, key ) { var autoClosed; - var settings; - if ( key && key.name === 'tab' ) { return; } - settings = self._settings; + autoClosed = self._autoCloser.onKeypress( data, key ); - if ( settings.autoClosePairs ) { - autoClosed = self._autoCloser.onKeypress( data, key ); - } - if ( settings.completionPreviews ) { - // If auto-closing was performed, explicitly remove any currently displayed completion preview... - if ( autoClosed ) { - self._previewCompleter.clear(); - } else { - self._previewCompleter.onKeypress( data, key ); - } + // If auto-closing was performed, explicitly remove any currently displayed completion preview... + if ( autoClosed ) { + self._previewCompleter.clear(); } + self._previewCompleter.onKeypress( data, key ); } /** @@ -360,6 +365,9 @@ function REPL( options ) { * @private */ function onClose() { + ostream.end(); + ostream.unpipe(); + debug( 'Readline interface closed.' ); self._istream.removeListener( 'keypress', onKeypress ); @@ -367,6 +375,17 @@ function REPL( options ) { self.emit( 'exit' ); } + /** + * Callback invoked upon receiving a "SIGWINCH" event (i.e., a terminal/console resize event). + * + * @private + * @returns {void} + */ + function onSIGWINCH() { + debug( 'Received a SIGWINCH event. Terminal was resized.' ); + self._ostream.onResize(); + } + /** * Callback invoked upon receiving a "SIGINT" event (e.g., Ctrl-C). * @@ -464,6 +483,96 @@ setNonEnumerableReadOnly( REPL.prototype, '_prompt', function prompt() { return inputPrompt( this._inputPrompt, this._count ); }); +/** +* Returns the REPL viewport. +* +* @name viewport +* @memberof Repl.prototype +* @type {Function} +* @returns {(Array|null)} viewport dimensions (or null) +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // Query the REPL viewport: +* var v = repl.viewport(); +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'viewport', function viewport() { + if ( !this._isTTY ) { + return null; + } + if ( this._wstream.rows && this._wstream.columns ) { + return [ this._wstream.rows, this._wstream.columns ]; + } + return null; +}); + +/** +* Returns the REPL viewport height. +* +* @name viewportHeight +* @memberof Repl.prototype +* @type {Function} +* @returns {integer} viewport height +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // Query the REPL viewport height: +* var v = repl.viewportHeight(); +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'viewportHeight', function viewportHeight() { + if ( !this._isTTY || !isNumber( this._wstream.rows ) ) { + return -1; + } + return this._wstream.rows; +}); + +/** +* Returns the REPL viewport width. +* +* @name viewportWidth +* @memberof Repl.prototype +* @type {Function} +* @returns {integer} viewport width +* +* @example +* var debug = require( '@stdlib/streams/node/debug-sink' ); +* +* // Create a new REPL: +* var repl = new REPL({ +* 'output': debug() +* }); +* +* // Query the REPL viewport width: +* var v = repl.viewportWidth(); +* +* // Close the REPL: +* repl.close(); +*/ +setNonEnumerableReadOnly( REPL.prototype, 'viewportWidth', function viewportWidth() { + if ( !this._isTTY || !isNumber( this._wstream.columns ) ) { + return -1; + } + return this._wstream.columns; +}); + /** * Creates a REPL context. * @@ -1070,6 +1179,32 @@ setNonEnumerableReadOnly( REPL.prototype, 'settings', function settings() { this._settings[ name ] = value; debug( 'Successfully updated setting: `%s`.', name ); + if ( name === 'completionPreviews' ) { + if ( value ) { + this._previewCompleter.enable(); + } else { + this._previewCompleter.disable(); + } + } else if ( name === 'autoClosePairs' ) { + if ( value ) { + this._autoCloser.enableAutoClose(); + } else { + this._autoCloser.disableAutoClose(); + } + } else if ( name === 'autoDeletePairs' ) { + if ( value ) { + this._autoCloser.enableAutoDelete(); + } else { + this._autoCloser.disableAutoDelete(); + } + } else if ( name === 'autoPage' ) { + if ( value ) { + this._ostream.enablePaging(); + } else { + this._ostream.disablePaging(); + } + } + return this; }); diff --git a/lib/output_stream.js b/lib/output_stream.js new file mode 100644 index 00000000..50c81194 --- /dev/null +++ b/lib/output_stream.js @@ -0,0 +1,525 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* eslint-disable no-restricted-syntax, no-invalid-this */ + +'use strict'; + +// MODULES // + +var readline = require( 'readline' ); +var logger = require( 'debug' ); +var Transform = require( 'readable-stream' ).Transform; +var inherit = require( '@stdlib/utils/inherit' ); +var nextTick = require( '@stdlib/utils/next-tick' ); +var setNonEnumerableReadOnly = require( '@stdlib/utils/define-nonenumerable-read-only-property' ); +var setNonEnumerableReadOnlyAccessor = require( '@stdlib/utils/define-nonenumerable-read-only-accessor' ); // eslint-disable-line id-length +var repeat = require( '@stdlib/string/repeat' ); +var RE_EOL = require( '@stdlib/regexp/eol' ).REGEXP; +var displayPrompt = require( './display_prompt.js' ); + + +// VARIABLES // + +var debug = logger( 'repl:output_stream' ); + +// Define the minimum viewport height necessary to support paging: +var MIN_VIEWPORT_HEIGHT = 6; // number of rows + +// Define the number of rows which are reserved for displaying the paging UI (including the input prompt): +var RESERVED_PAGING_ROWS = 4; + +// Define pager instructions: +var INSTRUCTIONS = '\u001b[1mUse UP/DOWN arrow keys to scroll. Press q to exit...\u001b[22m'; + + +// FUNCTIONS // + +/** +* Returns a list of indices corresponding to line segments. +* +* @private +* @param {string} str - input string +* @returns {Array} list of indices +* +* @example +* var str = 'foo\nbar\nbeep\nboop\n'; +* +* var idx = findLineSegments( str ); +* // returns [ 0, 4, 8, 13, 18 ] +*/ +function findLineSegments( str ) { + var lines; + var out; + var i; + + lines = str.split( RE_EOL ); // FIXME: this approach is not robust for three reasons: (1) line breaks may include carriage returns, not just line feeds (so the `+1` may not be appropriate), (2) it does not consider line feeds which are escaped (e.g., `\\\n`), and (3) assumes that line breaks map directly to displayed lines (while true most of the line for text which has been wrapped to 80 chars, such as repl.txt files, this may not be true for all outputs, some of which may spill across multiple lines) + out = [ 0 ]; + for ( i = 0; i < lines.length-1; i++ ) { + out.push( out[ i ] + lines[ i ].length + 1 ); + } + return out; +} + + +// MAIN // + +/** +* REPL output stream. +* +* @private +* @constructor +* @param {REPL} repl - REPL instance +* @param {boolean} autoPage - boolean indicating whether auto-paging should be initially enabled +* @returns {OutputStream} transform stream +*/ +function OutputStream( repl, autoPage ) { + if ( !( this instanceof OutputStream ) ) { + return new OutputStream( repl, autoPage ); + } + debug( 'Creating an output stream...' ); + Transform.call( this, { + 'encoding': 'utf8', + 'decodeString': false + }); + + // Cache a reference to the REPL instance: + this._repl = repl; + + // Initialize a flag indicating whether "paging" is enabled: + this._paging = autoPage; + + // Initialize a flag indicating whether "paging" is currently active: + this._isPaging = false; + + // Initialize a buffer for caching streamed chunks: + this._buffer = ''; + + // Initialize a buffer for storing line breaks: + this._indices = []; + + // Initialize a variable for tracking the current scroll position: + this._idx = -1; + + // Initialize a flag indicating whether the stream has been destroyed: + this._destroyed = false; + + return this; +} + +/** +* Inherit from the `Transform` prototype. +*/ +inherit( OutputStream, Transform ); + +/** +* Implements the `_transform` method. +* +* @private +* @name _transform +* @memberof OutputStream.prototype +* @type {Function} +* @param {(Buffer|string)} chunk - streamed chunk +* @param {string} encoding - Buffer encoding +* @param {Callback} clbk - callback to invoke after transformed the streamed chunk +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_transform', function transform( chunk, encoding, clbk ) { + var indices; + + // debug( 'Received a new chunk. Chunk: %s. Encoding: %s.', chunk.toString(), encoding ); // note: generally, one should not need to log each chunk, as doing so can create significant debugging noise, but we leave it here as a comment to toggle on/off in order to inspect individual chunks + if ( !this._paging || this._isPaging ) { // note: the only time we want to check for whether to page is when paging is enabled, but we're not currently paging + clbk( null, chunk ); + return; + } + chunk = chunk.toString(); + + // Check whether paging is necessary... + indices = findLineSegments( chunk ); + if ( !this._isScrollable( indices.length ) ) { + clbk( null, chunk ); + return; + } + // Cache the current chunk: + this._buffer = chunk; + + // Store the line segement indices: + this._indices = indices; + + // Toggle the "paging" mode: + this._isPaging = true; + + // Reset the scroll position: + this._idx = 0; + + debug( 'Displaying pager view...' ); + this._hideCursor(); + this._showPager(); + + clbk(); +}); + +/** +* Gracefully destroys a stream, providing backward compatibility. +* +* @private +* @name destroy +* @memberof OutputStream.prototype +* @type {Function} +* @param {Object} [error] - optional error message +* @returns {OutputStream} stream instance +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_destroy', function destroy( error ) { + var self; + if ( this._destroyed ) { + debug( 'Attempted to destroy an already destroyed stream.' ); + return this; + } + self = this; + this._destroyed = true; + + nextTick( close ); + + return this; + + /** + * Closes a stream. + * + * @private + */ + function close() { + if ( error ) { + debug( 'Stream was destroyed due to an error. Error: %s.', JSON.stringify( error ) ); + self.emit( 'error', error ); + } + debug( 'Closing the stream...' ); + self.emit( 'close' ); + } +}); + +/** +* Hides the cursor. +* +* @private +* @name _hideCursor +* @memberof OutputStream.prototype +* @type {Function} +* @returns {OutputStream} output stream +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_hideCursor', function hideCursor() { + debug( 'Hiding cursor...' ); + this.push( '\u001B[?25l' ); + return this; +}); + +/** +* Displays the cursor. +* +* @private +* @name _showCursor +* @memberof OutputStream.prototype +* @type {Function} +* @returns {OutputStream} output stream +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_showCursor', function showCursor() { + debug( 'Showing cursor...' ); + this.push( '\u001B[?25h' ); + return this; +}); + +/** +* Resets the cursor position during paging. +* +* @private +* @name _resetCursor +* @memberof OutputStream.prototype +* @type {Function} +* @returns {OutputStream} output stream +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_resetCursor', function resetCursor() { + debug( 'Resetting cursor position...' ); + readline.moveCursor( this, 0, -this._repl.viewportHeight() ); + return this; +}); + +/** +* Returns the number of lines available for showing a pager. +* +* @private +* @name _pagerHeight +* @memberof OutputStream.prototype +* @type {Function} +* @returns {integer} pager height +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_pagerHeight', function pagerHeight() { + var vh = this._repl.viewportHeight(); + if ( vh < MIN_VIEWPORT_HEIGHT ) { + return -1; + } + return vh - RESERVED_PAGING_ROWS; +}); + +/** +* Checks whether content having a specified number of lines is unable to fit within the current viewport. +* +* @private +* @name _isScrollable +* @memberof OutputStream.prototype +* @type {Function} +* @param {NonNegativeInteger} N - number of lines +* @returns {boolean} boolean indicating whether content is "scrollable" +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_isScrollable', function isScrollable( N ) { + var h = this._pagerHeight(); + return ( h > 0 && N > h ); +}); + +/** +* Shows the pager. +* +* @private +* @name _showPager +* @memberof OutputStream.prototype +* @type {Function} +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_showPager', function showPager() { + var height; + + // Resolve the current pager height: + height = this._pagerHeight(); + debug( 'Current pager height: %d', height ); + + debug( 'Displaying initial pager content...' ); + this.push( '\n' + this._buffer.substring( this._indices[ 0 ], this._indices[ height ] - 1 ) + '\n' ); + + // Draw a seperator to denote that the output is scrollable below: + this.push( repeat( '_', this._repl.viewportWidth() ) + '\n' ); + + // Display scroll instructions: + this.push( INSTRUCTIONS ); + + // Reset the cursor position: + this._resetCursor(); +}); + +/** +* Closes the pager. +* +* @private +* @name _closePager +* @memberof OutputStream.prototype +* @type {Function} +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_closePager', function closePager() { + var tmp; + + // Reset paging parameters: + this._isPaging = false; + this._idx = -1; + + // Clear previous output: + readline.clearScreenDown( this ); + + // Write the original data to the output stream: + tmp = this._paging; // cache the flag indicating whether paging is enabled + this._paging = false; // temporarily bypass paging + this.write( '\n' + this._buffer ); + this._paging = tmp; + + // Return to normal execution by displaying the prompt: + displayPrompt( this._repl, false ); + this._showCursor(); + + // Reset the internal paging buffers: + this._buffer = ''; + this._indices.length = 0; +}); + +/** +* Scrolls up. +* +* @private +* @name _scrollUp +* @memberof OutputStream.prototype +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_scrollUp', function scrollUp() { + // If we are already at the top of the page, we cannot scroll any further... + if ( this._idx <= 0 ) { + return; + } + this._idx -= 1; + this._redraw(); +}); + +/** +* Scrolls down. +* +* @private +* @name _scrollDown +* @memberof OutputStream.prototype +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_scrollDown', function scrollDown() { + // If we are already at the bottom of the page, we cannot scroll any further... + if ( ( this._pagerHeight() + this._idx ) >= this._indices.length ) { + return; + } + this._idx += 1; + return this._redraw(); +}); + +/** +* Redraws the page content. +* +* @private +* @name _redraw +* @memberof OutputStream.prototype +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, '_redraw', function redraw() { + var height; + var width; + var str; + var idx; + + idx = this._idx; + + // Resolve the current viewport: + width = this._repl.viewportWidth(); + + // Resolve the current pager height: + height = this._pagerHeight(); + + // If we're not showing the first line of content, display a separator to convey that one can scroll up to see additional content... + str = ''; + if ( idx > 0 ) { + str += '\n' + repeat( '_', width ) + '\n'; + } else { + str += '\n\n'; + } + // If we're not already showing the last line of content, display, in addition to the page content, a separator to convey that one can scroll down to see additional content... + if ( ( height + idx ) < this._indices.length ) { + str += this._buffer.substring( this._indices[ idx ], this._indices[ height+idx ] - 1 ) + '\n'; + str += repeat( '_', width ) + '\n'; + } else { + str += this._buffer.substring( this._indices[ idx ] ) + '\n'; + str += '\n'; + } + // Display pager instructions: + str += INSTRUCTIONS; + + // Clear previous output: + readline.clearScreenDown( this ); + + // Display the updated content: + this.write( str ); + this._resetCursor(); +}); + +/** +* Disables paging. +* +* @name disablePaging +* @memberof OutputStream.prototype +* @type {Function} +* @returns {OutputStream} output stream +*/ +setNonEnumerableReadOnly( OutputStream.prototype, 'disablePaging', function disablePaging() { + debug( 'Disabling paging...' ); + this._paging = false; + return this; +}); + +/** +* Enables paging. +* +* @name enablePaging +* @memberof OutputStream.prototype +* @type {Function} +* @returns {OutputStream} output stream +*/ +setNonEnumerableReadOnly( OutputStream.prototype, 'enablePaging', function enablePaging() { + debug( 'Enabling paging...' ); + this._paging = true; + return this; +}); + +/** +* Boolean indicating whether the output stream is currently in "paging" mode. +* +* @name isPaging +* @memberof OutputStream.prototype +* @type {Boolean} +*/ +setNonEnumerableReadOnlyAccessor( OutputStream.prototype, 'isPaging', function isPaging() { + return this._isPaging; +}); + +/** +* Callback which should be invoked **before** a "keypress" event is processed by a readline interface. +* +* @name beforeKeypress +* @memberof OutputStream.prototype +* @param {string} data - input data +* @param {(Object|void)} key - key object +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, 'beforeKeypress', function beforeKeypress( data, key ) { + if ( !this._isPaging ) { + return; + } + if ( key.name === 'up' ) { + debug( 'Received an UP keypress event. Scrolling up the page...' ); + return this._scrollUp(); + } + if ( key.name === 'down' ) { + debug( 'Received a DOWN keypress event. Scrolling down the page...' ); + return this._scrollDown(); + } + // Check whether the user wants to stop paging... + if ( key.name === 'q' || ( key.name === 'c' && key.ctrl ) ) { + debug( 'Closing pager view...' ); + return this._closePager(); + } +}); + +/** +* Callback which should be invoked upon a "resize" event. +* +* @name onResize +* @memberof OutputStream.prototype +* @returns {void} +*/ +setNonEnumerableReadOnly( OutputStream.prototype, 'onResize', function onResize() { + if ( !this._isPaging ) { + return; + } + if ( !this._isScrollable( this._indices.length ) ) { + this._closePager(); + return; + } + this._redraw(); +}); + + +// EXPORTS // + +module.exports = OutputStream; diff --git a/lib/settings.js b/lib/settings.js index caa2e40d..b60354e6 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -36,6 +36,10 @@ var SETTINGS = { 'desc': 'Automatically delete adjacent matching brackets, parentheses, and quotes.', 'type': 'boolean' }, + 'autoPage': { + 'desc': 'Automatically page return values whose display size exceeds the visible screen.', + 'type': 'boolean' + }, 'completionPreviews': { 'desc': 'Enable the display of completion previews for auto-completion.', 'type': 'boolean' diff --git a/package.json b/package.json index 462ad6f0..277a13f0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "stdlib-repl": "./bin/cli" }, "main": "./lib", - "browser": "./lib/browser/index.js", + "browser": { + "./lib": "./lib/browser/index.js", + "process": "process/" + }, "directories": { "benchmark": "./benchmark", "data": "./data", @@ -60,7 +63,8 @@ "acorn": "^8.1.0", "acorn-loose": "^8.0.2", "acorn-walk": "^8.0.2", - "debug": "^2.6.9" + "debug": "^2.6.9", + "readable-stream": "^2.1.4" }, "devDependencies": { "@stdlib/bench": "github:stdlib-js/bench#main", diff --git a/test/integration/fixtures/repl.js b/test/integration/fixtures/repl.js index d59238dd..ca0bcf61 100644 --- a/test/integration/fixtures/repl.js +++ b/test/integration/fixtures/repl.js @@ -45,7 +45,8 @@ function defaults() { 'sandbox': true, 'timeout': 10000, 'welcome': '', - 'quiet': true + 'quiet': true, + 'tty': null }; } @@ -58,6 +59,9 @@ function defaults() { * @private * @param {Options} options - REPL options * @param {WritableStream} options.input - input stream +* @param {Object} [options.tty] - TTY options +* @param {integer} [options.tty.columns] - number of terminal columns +* @param {integer} [options.tty.rows] - number of terminal rows * @param {Callback} clbk - callback to invoke upon closing a REPL * @throws {Error} must provide an input stream * @throws {TypeError} second argument must be a function @@ -106,6 +110,11 @@ function mock( options, clbk ) { // If we were not provided an output stream, create a default output stream... if ( !opts.output ) { opts.output = inspectSinkStream( onWrite ); + if ( opts.tty ) { + // Mock a TTY write stream (see https://nodejs.org/api/tty.html#class-ttywritestream): + opts.output.columns = opts.tty.columns; + opts.output.rows = opts.tty.rows; + } } // Initialize an array for storing streamed data: data = []; diff --git a/test/integration/test.auto_close_pairs.js b/test/integration/test.auto_close_pairs.js index 5c652ad5..2da94b40 100644 --- a/test/integration/test.auto_close_pairs.js +++ b/test/integration/test.auto_close_pairs.js @@ -143,6 +143,7 @@ function defaultSettings() { return { 'autoClosePairs': false, 'autoDeletePairs': false, + 'autoPage': false, 'completionPreviews': false }; } diff --git a/test/integration/test.auto_delete_pairs.js b/test/integration/test.auto_delete_pairs.js index 73c0858a..14b6b654 100644 --- a/test/integration/test.auto_delete_pairs.js +++ b/test/integration/test.auto_delete_pairs.js @@ -96,7 +96,7 @@ function processOutput( raw, flg ) { for ( i = raw.length-1; i >= 0; i-- ) { // Check whether the screen display was erased, as the next element is the refreshed line... if ( raw[ i ] === '\u001b[0J' ) { - return raw[ i+1 ]; + return trim( raw[ i+1 ] ); } } } @@ -111,6 +111,7 @@ function defaultSettings() { return { 'autoClosePairs': false, 'autoDeletePairs': false, + 'autoPage': false, 'completionPreviews': false }; } diff --git a/test/integration/test.auto_page.js b/test/integration/test.auto_page.js new file mode 100644 index 00000000..09561ff8 --- /dev/null +++ b/test/integration/test.auto_page.js @@ -0,0 +1,545 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +'use strict'; + +// MODULES // + +var tape = require( 'tape' ); +var DebugStream = require( '@stdlib/streams/node/debug' ); +var slice = require( '@stdlib/array/slice' ); +var repeat = require( '@stdlib/string/repeat' ); +var replace = require( '@stdlib/string/replace' ); +var min = require( '@stdlib/math/base/special/min' ); +var repl = require( './fixtures/repl.js' ); + + +// VARIABLES // + +var RESERVED_PAGING_ROWS = 4; +var FIXTURE = [ + 'This is line 1 of the long text.', + 'This is line 2 of the long text.', + 'This is line 3 of the long text.', + 'This is line 4 of the long text.', + 'This is line 5 of the long text.', + 'This is line 6 of the long text.', + 'This is line 7 of the long text.', + 'This is line 8 of the long text.', + 'This is line 9 of the long text.', + 'This is line 10 of the long text.' +].join( '\\n' ); + + +// FUNCTIONS // + +/** +* Returns default settings. +* +* @private +* @returns {Object} default settings +*/ +function defaultSettings() { + return { + 'autoDeletePairs': false, + 'autoClosePairs': false, + 'completionPreviews': false, + 'autoPage': true + }; +} + +/** +* Generates expected paged output based on the given page height and text. +* +* @private +* @param {string} text - text to be paged +* @param {number} pageHeight - height of the page (excluding reserved UI rows) +* @param {number} pageWidth - width of the page +* @param {number} index - scroll index of the page +* @returns {string} expected paged output +*/ +function generateExpectedPage( text, pageHeight, pageWidth, index ) { + var lines; + var page; + var out; + + // Generate the page text: + lines = text.split( '\\n' ); + index = min( index, lines.length - pageHeight ); + page = slice( lines, index, pageHeight + index ).join( '\n' ); + + // Construct the expected output: + out = '\n'; + if ( index > 0 ) { + out += repeat( '_', pageWidth ) + '\n'; + } + out += page; + out += '\n'; + if ( index < lines.length - pageHeight ) { + out += repeat( '_', pageWidth ); + } + out += '\n'; + out += '\x1B[1mUse UP/DOWN arrow keys to scroll. Press q to exit...\x1B[22m'; + + return out; +} + + +// TESTS // + +tape( 'main export is a function', function test( t ) { + t.ok( true, __filename ); + t.strictEqual( typeof repl, 'function', 'main export is a function' ); + t.end(); +}); + +tape( 'a REPL instance supports auto-paging long outputs', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Declare a userDoc from fixture: + istream.write( 'userDoc( "foo", "' + FIXTURE + '" );' ); + + // Call the help method to write the fixture: + istream.write( 'help( "foo" )\n' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check for the hidden cursor: + t.strictEqual( data[ data.length - 5 ], '\u001b[?25l', 'returns expected value' ); + + // Check for expected page content: + expected = generateExpectedPage( FIXTURE, opts.tty.rows - RESERVED_PAGING_ROWS, opts.tty.columns, 0 ); // eslint-disable-line max-len + actual = slice( data, data.length - 4, data.length - 1 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the cursor is returned back to the prompt: + t.strictEqual( data[ data.length - 1 ], '\u001b[10A', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance avoids auto-paging short outputs', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 15, // height can accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Declare a userDoc from fixture: + istream.write( 'userDoc( "foo", "' + FIXTURE + '" );' ); + + // Call the help method to write the fixture: + istream.write( 'help( "foo" )\n' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the output is printed without paging: + expected = replace( FIXTURE, '\\n', '\n' ) + '\n'; + t.strictEqual( data[ data.length - 1 ], expected, 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports paging using the `pager` command', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Disable auto-paging: + r.settings( 'autoPage', false ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check for the hidden cursor: + t.strictEqual( data[ data.length - 5 ], '\u001b[?25l', 'returns expected value' ); + + // Check for expected page content: + expected = generateExpectedPage( FIXTURE, opts.tty.rows - RESERVED_PAGING_ROWS, opts.tty.columns, 0 ); // eslint-disable-line max-len + actual = slice( data, data.length - 4, data.length - 1 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the cursor is returned back to the prompt: + t.strictEqual( data[ data.length - 1 ], '\u001b[10A', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports scrolling using the `down` arrow key when paging', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // Scroll down by simulating pressing the DOWN arrow key: + istream.write( '\u001b[1B' ); + istream.write( '\u001b[1B' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the previous page output was cleared: + t.strictEqual( data[ data.length - 3 ], '\u001b[0J', 'returns expected value' ); + + // Check for expected page content: + expected = generateExpectedPage( FIXTURE, opts.tty.rows - RESERVED_PAGING_ROWS, opts.tty.columns, 2 ); // eslint-disable-line max-len + actual = slice( data, data.length - 2, data.length - 1 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the cursor is returned back to the prompt: + t.strictEqual( data[ data.length - 1 ], '\u001b[10A', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports scrolling using the `up` arrow key when paging', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // As we start at the top of the paged content, simulate pressing the DOWN arrow key to scroll down the page: + istream.write( '\u001b[1B' ); + istream.write( '\u001b[1B' ); + istream.write( '\u001b[1B' ); + + // Scroll up, but not to the top, by simulating the pressing of the UP arrow key: + istream.write( '\u001b[1A' ); + istream.write( '\u001b[1A' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the previous page output was cleared: + t.strictEqual( data[ data.length - 3 ], '\u001b[0J', 'returns expected value' ); + + // Check for expected page content: + expected = generateExpectedPage( FIXTURE, opts.tty.rows - RESERVED_PAGING_ROWS, opts.tty.columns, 1 ); // eslint-disable-line max-len + actual = slice( data, data.length - 2, data.length - 1 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the cursor is returned back to the prompt: + t.strictEqual( data[ data.length - 1 ], '\u001b[10A', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports scrolling until the end of the page', function test( t ) { + var istream; + var opts; + var r; + var i; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // Scroll down until reaching the end of the page... + for ( i = 0; i < FIXTURE.length+10; i++ ) { + istream.write( '\u001b[1B' ); + } + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the previous page output was cleared: + t.strictEqual( data[ data.length - 3 ], '\u001b[0J', 'returns expected value' ); + + // Check for expected page content: + expected = generateExpectedPage( FIXTURE, opts.tty.rows - RESERVED_PAGING_ROWS, opts.tty.columns, 9 ); // eslint-disable-line max-len + actual = slice( data, data.length - 2, data.length - 1 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the cursor is returned back to the prompt: + t.strictEqual( data[ data.length - 1 ], '\u001b[10A', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports exiting the pager by pressing `q`', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'inputPrompt': '> ', + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // Simulate pressing `q` to exit the pager: + istream.write( 'q' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the page output was cleared: + t.strictEqual( data[ data.length - 8 ], '\u001b[0J', 'returns expected value' ); + + // Check for expected page content: + expected = '\n' + replace( FIXTURE, '\\n', '\n' ) + '\n'; + actual = slice( data, data.length - 7, data.length - 5 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if the prompt was re-displayed: + t.strictEqual( data[ data.length - 3 ], '> ', 'returns expected value' ); + t.strictEqual( data[ data.length - 2 ], '\x1B[3G', 'returns expected value' ); // cursor is to the right of the prompt + + // Check if the cursor is visible: + t.strictEqual( data[ data.length - 1 ], '\x1B[?25h', 'returns expected value' ); + + t.end(); + } +}); + +tape( 'a REPL instance supports exiting the pager after a SIGINT event', function test( t ) { + var istream; + var opts; + var r; + + istream = new DebugStream({ + 'name': 'repl-input-stream' + }); + opts = { + 'input': istream, + 'settings': defaultSettings(), + 'inputPrompt': '> ', + 'tty': { + 'rows': 10, // height cannot accomodate the fixture + 'columns': 80 + } + }; + r = repl( opts, onClose ); + + // Call the pager command to write the fixture: + istream.write( 'pager( "' + FIXTURE + '" )\n' ); + + // Trigger a SIGINT event to exit the pager: + istream.write( '\u0003' ); + + // Close the input stream: + istream.end(); + + // Close the REPL: + r.close(); + + function onClose( error, data ) { + var expected; + var actual; + + if ( error ) { + t.fail( error.message ); + return; + } + // Check if the page output was cleared: + t.strictEqual( data[ data.length - 8 ], '\u001b[0J', 'returns expected value' ); + + // Check for expected page content: + expected = '\n' + replace( FIXTURE, '\\n', '\n' ) + '\n'; + actual = slice( data, data.length - 7, data.length - 5 ).join( '' ); + t.strictEqual( actual, expected, 'returns expected value' ); + + // Check if that the prompt was re-displayed: + t.strictEqual( data[ data.length - 3 ], '> ', 'returns expected value' ); + t.strictEqual( data[ data.length - 2 ], '\x1B[3G', 'returns expected value' ); // cursor is to the right of the prompt + + // Check if the cursor is visible: + t.strictEqual( data[ data.length - 1 ], '\x1B[?25h', 'returns expected value' ); + + t.end(); + } +}); diff --git a/test/integration/test.completion_previews.js b/test/integration/test.completion_previews.js index 5fbb90da..04c36320 100644 --- a/test/integration/test.completion_previews.js +++ b/test/integration/test.completion_previews.js @@ -43,7 +43,10 @@ tape( 'a REPL instance supports displaying a completion preview of user-defined 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -83,7 +86,10 @@ tape( 'a REPL instance supports displaying a completion preview for common prefi 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -124,7 +130,10 @@ tape( 'a REPL instance supports displaying a completion preview for recognized i 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -164,7 +173,10 @@ tape( 'a REPL instance supports displaying a completion preview when a cursor is 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -213,7 +225,10 @@ tape( 'a REPL instance supports auto-completing a completion candidate by moving 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -261,7 +276,10 @@ tape( 'a REPL instance supports auto-completing a completion preview and executi opts = { 'input': istream, 'inputPrompt': '> ', - 'outputPrompt': '' + 'outputPrompt': '', + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -310,7 +328,10 @@ tape( 'a REPL instance does not display a completion preview when no completion 'name': 'repl-input-stream' }); opts = { - 'input': istream + 'input': istream, + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose ); @@ -364,7 +385,10 @@ tape( 'a REPL instance does not display a completion preview once a user enters opts = { 'input': istream, 'inputPrompt': '> ', - 'outputPrompt': '' + 'outputPrompt': '', + 'settings': { + 'autoPage': false + } }; r = repl( opts, onClose );