Skip to content
This repository has been archived by the owner on Feb 11, 2025. It is now read-only.

use readline for prompt() via magic #96

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 49 additions & 161 deletions prybar_assets/nodejs/input-sync.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,19 @@
const { readSync, writeSync, openSync } = require("fs");
const { isatty } = require("tty");
const { readSync, writeSync, openSync } = require('fs');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes, this whole project has inconsistent quoting styles, so we get these painful prettier diffs. I opened #97 and #98 as one-click merge options for how to try to mitigate this if you want.

const { isatty } = require('tty');
const { Readable } = require('stream');
const { createInterface } = require('readline');

const buf = Buffer.alloc(1);
const isTTY = isatty(process.stdin.fd);

/**
* The escape (excluding \x1b) to move the cursur right one.
*
* This is what the right arrow key translates to in raw mode.
*/
const cursorRight = "[C";
/**
* The escape (excluding \x1b) to move the cursur left one.
*
* This is what the left arrow key translates to in raw mode.
*/
const cursorLeft = "[D";

/**
* The ASCII character sent when the tty is in raw mode and backspace is pressed.
*/
const del = "\x7f";

/**
* The ASCII characcter sent when the tty is in raw mode and Ctrl+C is pressed.
* The ASCII character sent when the tty is in raw mode and Ctrl+C is pressed.
*/
const endOfText = "\x03";
const endOfText = '\x03';
/**
* The ASCII character sent when the tty is in raw mode and Ctrl+D is pressed.
*/
const endOfTransmission = "\x04";
const endOfTransmission = '\x04';

/**
* Reads a single byte from stdin to buf.
Expand All @@ -47,8 +32,37 @@ function readByteSync() {
const stdinFd = isTTY
? // We can't just use process.stdin.fd here since node has some getter shenanigans
// which cause sync reads to throw
openSync("/dev/tty", "r")
: openSync("/dev/stdin", "r");
openSync('/dev/tty', 'r')
: openSync('/dev/stdin', 'r');

class SyncReadable extends Readable {
constructor(fd) {
super();

this.fd = fd;
}

_read() {}

readNext() {
readByteSync();

this.push(
// copy the buffer to be safe
Buffer.concat([buf])
);
}
}

const rd = new SyncReadable(stdinFd);
// prime reader
rd.read();

const rl = createInterface({
input: rd,
output: process.stdout,
terminal: isTTY,
});

/**
* Writes output to stdout.
Expand Down Expand Up @@ -97,39 +111,6 @@ function ensureRawMode(cb) {
return ret;
}

/**
* Handles ANSI escapes from stdin.
*
* @return {string | -1 | 1} String if the escape isn't a left or right arrow .
* Otherwise, -1 on left arrow and 1 on right arrow
*/
function handleArrowKey() {
if (!readByteSync()) {
return "^";
}

let str = buf.toString("binary");

if (str !== "[") {
return `^${str}`;
}

if (!readByteSync()) {
return `^${str}`;
}

str += buf.toString("binary");

switch (str) {
case cursorRight:
return 1;
case cursorLeft:
return -1;
default:
return `^${str}`;
}
}

/**
* Checks to see if the input character is what we get in raw mode for
* Ctrl+C or Ctrl+D, and if so sends the proc SIGINT>
Expand All @@ -142,115 +123,22 @@ function checkForSigs(char) {
}
}

/**
* Writes a string at an index of another string, appending as needed.
*
* @param {string} str The base string
* @param {string} other The new string which is being written
* @param {number} index The index at which the new string should start at.
* @return {string} str with other written at index.
*/
function insertAt(str, other, index) {
return [str.slice(0, index) + other + str.slice(index), index + other.length];
}

/**
* The ANSI escape code used to clear the contents of the current line to the right
* of the cursor.
*/
const escapeClearLineRight = "\x1b[K";

/**
* The escape used to move the cursor to a specific position in-line.
*
* @param {number} columnNum The position (starting at 1) in the current line which the cursor should be moved to.
*
*/
function escapeMoveCursorToColumn(columnNum) {
return `\x1b[${columnNum}G`;
}

/**
* Sets the current line to our promt + string w/ the cursor at the right index.
*
* @param {string} prompt The question's prompt
* @param {string} current The current string (what the user has input so far)
* @param {number} index The index that
*/
function displayPromptAndStr(prompt, current, index) {
writeTTYOutput(
// reset cursor position
"\r" +
// clear the rest of the line
// EL (Erase in Line ): in this case, as no number is speciifed,
// erases everything to the right of the cursor.
escapeClearLineRight +
// write the prompt
prompt +
// write the string
current +
escapeMoveCursorToColumn(prompt.length + index + 1)
);
}

/**
* Synchronously reads from stdin until `\n` or `\r`
*
* @param {string} prompt The prompt to be displayed
* @return {string} The input read (excluding newlines)
*/
function question(prompt) {
return ensureRawMode(() => {
let str = "";
let index = 0;
let result = null;

if (!isTTY) {
writeOutput(prompt);
}

for (;;) {
displayPromptAndStr(prompt, str, index);
const didRead = readByteSync();

if (!didRead) {
return str;
}

const char = buf.toString("binary");
checkForSigs(char);

if (char === "\n" || char === "\r") {
writeTTYOutput("\r\n");

return str;
} else if (isTTY && char === "\x1b") {
const ret = handleArrowKey();
rl.question(prompt, (d) => {
result = d;
});

// if ret is a number, its the difference for the index
if (typeof ret === "number") {
// Only move the cursor if it will be in a valid position.
const newIndex = index + ret;
// the index can be equal to the strs length, if that's the case we're appending to the string.
if (newIndex >= 0 && newIndex <= str.length) {
index = newIndex;
}
while (result == null) rd.readNext();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this eqeq intentional? I don't see any case that the coercion would be desirable.

If it is intentional, maybe leave a comment explaining it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is intentional. It's what's called a "nullish" check. it's somewhat unstandard but handy to know.

a == null returns true if a is nullish (null or undefined).


// otherwise, the escape wasn't a left or right arrow key,
// meaning we got an escaped version of the code.
} else {
[str, index] = insertAt(str, ret, index);
}
} else if (isTTY && char === del) {
if (index > 0) {
index--;
// remove the character at the old index
str = str.slice(0, index) + str.slice(index + 1);
}
} else {
[str, index] = insertAt(str, char, index);
}
}
});
return result;
}

/**
Expand All @@ -263,22 +151,22 @@ function question(prompt) {
*/
function keyInYNStrict(prompt) {
return ensureRawMode(() => {
writeOutput(`${prompt == null ? "Are you sure?" : prompt} [y/n]: `);
writeOutput(`${prompt == null ? 'Are you sure?' : prompt} [y/n]: `);

for (;;) {
const didRead = readByteSync();

if (!didRead) {
throw new Error("Unexpected EOF / end of input. Expected y/n.");
throw new Error('Unexpected EOF / end of input. Expected y/n.');
}

const char = buf.toString("binary");
const char = buf.toString('binary');
checkForSigs(char);

if (char.match(/[yn]/i)) {
writeTTYOutput(`${char}\r\n`);

return char === "y" || char === "Y";
return char === 'y' || char === 'Y';
}
}
});
Expand Down