From 6ea74c268a12325296a1d2e7fc31b02030ddb8bc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 12 May 2023 17:42:09 -0300 Subject: use ESM module syntax & minor cleanups The biggest change here is moving various element classes under more scope-specific directories, which helps to avoid circular dependencies and is just cleaner to navigate and expand in the future. Otherwise this is a largely uncritical port to ESM module syntax! There are probably a number of changes and other cleanups that remain much needed. Whenever I make changes to tui-lib it's hard to believe it's already been years since the previous time. First commits are from January 2017, and the code originates a month earlier in KAaRMNoD! --- util/CommandLineInterfacer.js | 91 ---- util/Flushable.js | 126 ------ util/TelnetInterfacer.js | 138 ------ util/ansi.js | 778 ++++++++++++++++---------------- util/count.js | 2 +- util/exception.js | 2 +- util/index.js | 11 + util/interfaces/CommandLineInterface.js | 92 ++++ util/interfaces/Flushable.js | 126 ++++++ util/interfaces/TelnetInterface.js | 139 ++++++ util/interfaces/index.js | 4 + util/smoothen.js | 2 +- util/telchars.js | 2 +- util/tui-app.js | 19 +- util/unichars.js | 2 +- util/waitForData.js | 2 +- util/wrap.js | 2 +- 17 files changed, 776 insertions(+), 762 deletions(-) delete mode 100644 util/CommandLineInterfacer.js delete mode 100644 util/Flushable.js delete mode 100644 util/TelnetInterfacer.js create mode 100644 util/index.js create mode 100644 util/interfaces/CommandLineInterface.js create mode 100644 util/interfaces/Flushable.js create mode 100644 util/interfaces/TelnetInterface.js create mode 100644 util/interfaces/index.js (limited to 'util') diff --git a/util/CommandLineInterfacer.js b/util/CommandLineInterfacer.js deleted file mode 100644 index d2007fb..0000000 --- a/util/CommandLineInterfacer.js +++ /dev/null @@ -1,91 +0,0 @@ -const EventEmitter = require('events') -const waitForData = require('./waitForData') -const ansi = require('./ansi') - -module.exports = class CommandLineInterfacer extends EventEmitter { - constructor(inStream = process.stdin, outStream = process.stdout, proc = process) { - super() - - this.inStream = inStream - this.outStream = outStream - this.process = proc - - inStream.on('data', buffer => { - this.emit('inputData', buffer) - }) - - inStream.setRawMode(true) - - proc.on('SIGWINCH', async buffer => { - this.emit('resize', await this.getScreenSize()) - }) - } - - async getScreenSize() { - const waitUntil = cond => waitForData(this.inStream, cond) - - // Get old cursor position.. - this.outStream.write(ansi.requestCursorPosition()) - const { options: oldCoords } = this.parseANSICommand( - await waitUntil(buf => ansi.isANSICommand(buf, 82)) - ) - - // Move far to the bottom right of the screen, then get cursor position.. - // (We could use moveCursor here, but the 0-index offset isn't really - // relevant.) - this.outStream.write(ansi.moveCursorRaw(9999, 9999)) - this.outStream.write(ansi.requestCursorPosition()) - const { options: sizeCoords } = this.parseANSICommand( - await waitUntil(buf => ansi.isANSICommand(buf, 82)) - ) - - // Restore to old cursor position.. (Using moveCursorRaw is actaully - // necessary here, since we'll be passing the coordinates returned from - // another ANSI command.) - this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) - - // And return dimensions. - const [ sizeLine, sizeCol ] = sizeCoords - return { - lines: sizeLine, cols: sizeCol, - width: sizeCol, height: sizeLine - } - } - - parseANSICommand(buffer) { - // Typically ANSI commands are written ESC[1;2;3;4C - // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and - // decimal 33 - // ..where [ and ; are the literal strings "[" and ";" - // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII - // that may last more than one byte (e.g. "15") - // ..where C is some number representing the code of the command - - if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { - throw new Error('Not an ANSI command') - } - - const options = [] - let curOption = '' - let commandCode = null - for (const val of buffer.slice(2)) { - if (48 <= val && val <= 57) { // 0124356789 - curOption = curOption.concat(val - 48) - } else { - options.push(parseInt(curOption)) - curOption = '' - - if (val !== 59) { // ; - commandCode = val - break - } - } - } - - return {code: commandCode, options: options} - } - - write(data) { - this.outStream.write(data) - } -} diff --git a/util/Flushable.js b/util/Flushable.js deleted file mode 100644 index 058d186..0000000 --- a/util/Flushable.js +++ /dev/null @@ -1,126 +0,0 @@ -const ansi = require('./ansi') -const unic = require('./unichars') - -module.exports = class Flushable { - // A writable that can be used to collect chunks of data before writing - // them. - - constructor(writable, shouldCompress = false) { - this.target = writable - - // Use the magical ANSI self-made compression method that probably - // doesn't *quite* work but should drastically decrease write size? - this.shouldCompress = shouldCompress - - // Whether or not to show compression statistics (original written size - // and ANSI-interpreted compressed size) in the output of flush. - this.shouldShowCompressionStatistics = false - - // Use resizeScreen if you plan on using the ANSI compressor! - this.screenLines = 24 - this.screenCols = 80 - this.lastFrame = undefined - - this.ended = false - this.paused = false - this.requestedFlush = false - - this.chunks = [] - } - - resizeScreen({lines, cols}) { - this.screenLines = lines - this.screenCols = cols - this.clearLastFrame() - } - - clearLastFrame() { - this.lastFrame = undefined - } - - write(what) { - this.chunks.push(what) - } - - flush() { - // If we're paused, we don't want to write, but we will keep a note that a - // flush was requested for when we unpause. - if (this.paused) { - this.requestedFlush = true - return - } - - // Don't write if we've ended. - if (this.ended) { - return - } - - // End if the target is destroyed. - // Yes, this relies on the target having a destroyed property - // Don't worry, it'll still work if there is no destroyed property though - // (I think) - if (this.target.destroyed) { - this.end() - return - } - - let toWrite = this.chunks.join('') - - if (this.shouldCompress) { - toWrite = this.compress(toWrite) - } - - try { - this.target.write(toWrite) - } catch(err) { - console.error('Flushable write error (ending):', err.message) - this.end() - } - - this.chunks = [] - } - - pause() { - this.paused = true - } - - resume() { - this.paused = false - - if (this.requestedFlush) { - this.flush() - } - } - - end() { - this.ended = true - } - - compress(toWrite) { - // TODO: customize screen size - const output = ansi.interpret( - toWrite, this.screenLines, this.screenCols, this.lastFrame - ) - - let { screen } = output - - this.lastFrame = output - - if (this.shouldShowCompressionStatistics) { - let msg = this.lastInterpretMessage - if (screen.length > 0 || !this.lastInterpretMessage) { - const pcSaved = Math.round(1000 - (1000 / toWrite.length * screen.length)) / 10 - const kbSaved = Math.round((toWrite.length - screen.length) / 100) / 10 - msg = this.lastInterpretMessage = ( - '(ANSI-interpret: ' + - `${toWrite.length} -> ${screen.length} ${pcSaved}% / ${kbSaved} KB saved)` - ) - } - screen += '\x1b[H\x1b[0m' - screen += msg + unic.BOX_H_DOUBLE.repeat(this.screenCols - msg.length) - this.lastFrame.oldLastChar.attributes = [] - } - - return screen - } -} diff --git a/util/TelnetInterfacer.js b/util/TelnetInterfacer.js deleted file mode 100644 index dc71157..0000000 --- a/util/TelnetInterfacer.js +++ /dev/null @@ -1,138 +0,0 @@ -const ansi = require('./ansi') -const waitForData = require('./waitForData') -const EventEmitter = require('events') - -module.exports = class TelnetInterfacer extends EventEmitter { - constructor(socket) { - super() - - this.socket = socket - - socket.on('data', buffer => { - if (buffer[0] === 255) { - this.handleTelnetData(buffer) - } else { - this.emit('inputData', buffer) - } - }) - - this.initTelnetOptions() - } - - initTelnetOptions() { - // Initializes various socket options, using telnet magic. - - // Disables linemode. - this.socket.write(Buffer.from([ - 255, 253, 34, // IAC DO LINEMODE - 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE - 255, 251, 1 // IAC WILL ECHO - ])) - - // Will SGA. Helps with putty apparently. - this.socket.write(Buffer.from([ - 255, 251, 3 // IAC WILL SGA - ])) - - this.socket.write(ansi.hideCursor()) - } - - cleanTelnetOptions() { - // Resets the telnet options and magic set in initTelnetOptions. - - this.socket.write(ansi.resetAttributes()) - this.socket.write(ansi.showCursor()) - } - - async getScreenSize() { - this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS - - let didWillNAWS = false - let didSBNAWS = false - let sb - - inputLoop: while (true) { - const data = await waitForData(this.socket) - - for (const command of this.parseTelnetCommands(data)) { - // WILL NAWS - if (command[1] === 251 && command[2] === 31) { - didWillNAWS = true - continue - } - - // SB NAWS - if (didWillNAWS && command[1] === 250 && command[2] === 31) { - didSBNAWS = true - sb = command.slice(3) - continue - } - - // SE - if (didSBNAWS && command[1] === 240) { // SE - break inputLoop - } - } - } - - return this.parseSBNAWS(sb) - } - - parseTelnetCommands(buffer) { - if (buffer[0] === 255) { - // Telnet IAC (Is A Command) - ignore - - // Split the data into multiple IAC commands if more than one IAC was - // sent. - const values = Array.from(buffer.values()) - const commands = [] - const curCmd = [255] - for (const value of values) { - if (value === 255) { // IAC - commands.push(Array.from(curCmd)) - curCmd.splice(1, curCmd.length) - continue - } - curCmd.push(value) - } - commands.push(curCmd) - - return commands - } else { - return [] - } - } - - write(data) { - this.socket.write(data) - } - - handleTelnetData(buffer) { - let didSBNAWS = false - let sbNAWS - - for (let command of this.parseTelnetCommands(buffer)) { - // SB NAWS - if (command[1] === 250 && command[2] === 31) { - didSBNAWS = true - sbNAWS = command.slice(3) - continue - } - - // SE - if (didSBNAWS && command[1] === 240) { // SE - didSBNAWS = false - this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) - this.emit('resize', this.parseSBNAWS(sbNAWS)) - continue - } - } - } - - parseSBNAWS(sb) { - const cols = (sb[0] << 8) + sb[1] - const lines = (sb[2] << 8) + sb[3] - - return { cols, lines, width: cols, height: lines } - } -} diff --git a/util/ansi.js b/util/ansi.js index ac511ed..2ae5166 100644 --- a/util/ansi.js +++ b/util/ansi.js @@ -1,498 +1,496 @@ -const wcwidth = require('wcwidth') +import wcwidth from 'wcwidth' -const ESC = '\x1b' +function isDigit(char) { + return '0123456789'.indexOf(char) >= 0 +} -const isDigit = char => '0123456789'.indexOf(char) >= 0 +export const ESC = '\x1b' + +// Attributes +export const A_RESET = 0 +export const A_BRIGHT = 1 +export const A_DIM = 2 +export const A_INVERT = 7 + +// Colors +export const C_BLACK = 30 +export const C_RED = 31 +export const C_GREEN = 32 +export const C_YELLOW = 33 +export const C_BLUE = 34 +export const C_MAGENTA = 35 +export const C_CYAN = 36 +export const C_WHITE = 37 +export const C_RESET = 39 + +export function clearScreen() { + // Clears the screen, removing any characters displayed, and resets the + // cursor position. + + return `${ESC}[2J` +} -const ansi = { - ESC, +export function moveCursorRaw(line, col) { + // Moves the cursor to the given line and column on the screen. + // Returns the pure ANSI code, with no modification to line or col. - // Attributes - A_RESET: 0, - A_BRIGHT: 1, - A_DIM: 2, - A_INVERT: 7, - C_BLACK: 30, - C_RED: 31, - C_GREEN: 32, - C_YELLOW: 33, - C_BLUE: 34, - C_MAGENTA: 35, - C_CYAN: 36, - C_WHITE: 37, - C_RESET: 39, + return `${ESC}[${line};${col}H` +} - clearScreen() { - // Clears the screen, removing any characters displayed, and resets the - // cursor position. +export function moveCursor(line, col) { + // Moves the cursor to the given line and column on the screen. + // Note that since in JavaScript indexes start at 0, but in ANSI codes + // the top left of the screen is (1, 1), this function adjusts the + // arguments to act as if the top left of the screen is (0, 0). - return `${ESC}[2J` - }, + return `${ESC}[${line + 1};${col + 1}H` +} - moveCursorRaw(line, col) { - // Moves the cursor to the given line and column on the screen. - // Returns the pure ANSI code, with no modification to line or col. +export function cleanCursor() { + // A combination of codes that generally cleans up the cursor. - return `${ESC}[${line};${col}H` - }, + return resetAttributes() + + stopTrackingMouse() + + showCursor() +} - moveCursor(line, col) { - // Moves the cursor to the given line and column on the screen. - // Note that since in JavaScript indexes start at 0, but in ANSI codes - // the top left of the screen is (1, 1), this function adjusts the - // arguments to act as if the top left of the screen is (0, 0). +export function hideCursor() { + // Makes the cursor invisible. - return `${ESC}[${line + 1};${col + 1}H` - }, + return `${ESC}[?25l` +} - cleanCursor() { - // A combination of codes that generally cleans up the cursor. +export function showCursor() { + // Makes the cursor visible. - return ansi.resetAttributes() + - ansi.stopTrackingMouse() + - ansi.showCursor() - }, + return `${ESC}[?25h` +} - hideCursor() { - // Makes the cursor invisible. +export function resetAttributes() { + // Resets all attributes, including text decorations, foreground and + // background color. - return `${ESC}[?25l` - }, + return `${ESC}[0m` +} - showCursor() { - // Makes the cursor visible. +export function setAttributes(attrs) { + // Set some raw attributes. See the attributes section of the ansi.js + // source code for attributes that can be used with this; A_RESET resets + // all attributes. - return `${ESC}[?25h` - }, + return `${ESC}[${attrs.join(';')}m` +} - resetAttributes() { - // Resets all attributes, including text decorations, foreground and - // background color. +export function setForeground(color) { + // Sets the foreground color to print text with. See C_(COLOR) for colors + // that can be used with this; C_RESET resets the foreground. + // + // If null or undefined is passed, this function will return a blank + // string (no ANSI escape codes). - return `${ESC}[0m` - }, + if (typeof color === 'undefined' || color === null) { + return '' + } - setAttributes(attrs) { - // Set some raw attributes. See the attributes section of the ansi.js - // source code for attributes that can be used with this; A_RESET resets - // all attributes. + return setAttributes([color]) +} - return `${ESC}[${attrs.join(';')}m` - }, +export function setBackground(color) { + // Sets the background color to print text with. Accepts the same arguments + // as setForeground (C_(COLOR), C_RESET, etc). + // + // Note that attributes such as A_BRIGHT and A_DIM apply apply to only the + // foreground, not the background. To set a bright or dim background, you + // can set the appropriate color as the foreground and then invert. - setForeground(color) { - // Sets the foreground color to print text with. See C_(COLOR) for colors - // that can be used with this; C_RESET resets the foreground. - // - // If null or undefined is passed, this function will return a blank - // string (no ANSI escape codes). + if (typeof color === 'undefined' || color === null) { + return '' + } - if (typeof color === 'undefined' || color === null) { - return '' - } + return setAttributes([color + 10]) +} - return ansi.setAttributes([color]) - }, +export function invert() { + // Inverts the foreground and background colors. - setBackground(color) { - // Sets the background color to print text with. Accepts the same arguments - // as setForeground (C_(COLOR), C_RESET, etc). - // - // Note that attributes such as A_BRIGHT and A_DIM apply apply to only the - // foreground, not the background. To set a bright or dim background, you - // can set the appropriate color as the foreground and then invert. + return `${ESC}[7m` +} - if (typeof color === 'undefined' || color === null) { - return '' - } +export function invertOff() { + // Un-inverts the foreground and backgrund colors. - return ansi.setAttributes([color + 10]) - }, + return `${ESC}[27m` +} - invert() { - // Inverts the foreground and background colors. +export function startTrackingMouse() { + return `${ESC}[?1002h` +} - return `${ESC}[7m` - }, +export function stopTrackingMouse() { + return `${ESC}[?1002l` +} - invertOff() { - // Un-inverts the foreground and backgrund colors. +export function requestCursorPosition() { + // Requests the position of the cursor. + // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), + // c is the column number (also 1-based), and R is the literal character + // 'R' (decimal code 82). - return `${ESC}[27m` - }, + return `${ESC}[6n` +} - startTrackingMouse() { - return `${ESC}[?1002h` - }, +export function enableAlternateScreen() { + // Enables alternate screen: + // "Xterm maintains two screen buffers. The normal screen buffer allows + // you to scroll back to view saved lines of output up to the maximum set + // by the saveLines resource. The alternate screen buffer is exactly as + // large as the display, contains no additional saved lines." - stopTrackingMouse() { - return `${ESC}[?1002l` - }, + return `${ESC}[?1049h` +} - requestCursorPosition() { - // Requests the position of the cursor. - // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), - // c is the column number (also 1-based), and R is the literal character - // 'R' (decimal code 82). +export function disableAlternateScreen() { + return `${ESC}[?1049l` +} - return `${ESC}[6n` - }, +export function measureColumns(text) { + // Returns the number of columns the given text takes. - enableAlternateScreen() { - // Enables alternate screen: - // "Xterm maintains two screen buffers. The normal screen buffer allows - // you to scroll back to view saved lines of output up to the maximum set - // by the saveLines resource. The alternate screen buffer is exactly as - // large as the display, contains no additional saved lines." + return wcwidth(text) +} - return `${ESC}[?1049h` - }, +export function trimToColumns(text, cols) { + // Trims off the end of the passed text so that its width doesn't exceed + // the size passed in columns. - disableAlternateScreen() { - return `${ESC}[?1049l` - }, + let out = '' + for (const char of text) { + if (measureColumns(out + char) <= cols) { + out += char + } else { + break + } + } + return out +} + +export function isANSICommand(buffer, code = null) { + return ( + buffer[0] === 0x1b && buffer[1] === 0x5b && + (code ? buffer[buffer.length - 1] === code : true) + ) +} - measureColumns(text) { - // Returns the number of columns the given text takes. +export function interpret(text, scrRows, scrCols, { + oldChars = null, oldLastChar = null, + oldScrRows = null, oldScrCols = null, + oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true +} = {}) { + // Interprets the given ansi code, more or less. - return wcwidth(text) - }, + const blank = { + attributes: [], + char: ' ' + } - trimToColumns(text, cols) { - // Trims off the end of the passed text so that its width doesn't exceed - // the size passed in columns. + const chars = new Array(scrRows * scrCols).fill(blank) - let out = '' - for (const char of text) { - if (ansi.measureColumns(out + char) <= cols) { - out += char - } else { - break + if (oldChars) { + for (let row = 0; row < scrRows && row < oldScrRows; row++) { + for (let col = 0; col < scrCols && col < oldScrCols; col++) { + chars[row * scrCols + col] = oldChars[row * oldScrCols + col] } } - return out - }, - - isANSICommand(buffer, code = null) { - return ( - buffer[0] === 0x1b && buffer[1] === 0x5b && - (code ? buffer[buffer.length - 1] === code : true) - ) - }, - - interpret(text, scrRows, scrCols, { - oldChars = null, oldLastChar = null, - oldScrRows = null, oldScrCols = null, - oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true - } = {}) { - // Interprets the given ansi code, more or less. - - const blank = { - attributes: [], - char: ' ' - } + } - const chars = new Array(scrRows * scrCols).fill(blank) + let showCursor = oldShowCursor + let cursorRow = oldCursorRow + let cursorCol = oldCursorCol + let attributes = [] - if (oldChars) { - for (let row = 0; row < scrRows && row < oldScrRows; row++) { - for (let col = 0; col < scrCols && col < oldScrCols; col++) { - chars[row * scrCols + col] = oldChars[row * oldScrCols + col] - } - } - } + for (let charI = 0; charI < text.length; charI++) { + const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1) + + if (text[charI] === ESC) { + charI++ - let showCursor = oldShowCursor - let cursorRow = oldCursorRow - let cursorCol = oldCursorCol - let attributes = [] + if (text[charI] !== '[') { + throw new Error('ESC not followed by [') + } - for (let charI = 0; charI < text.length; charI++) { - const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1) + charI++ - if (text[charI] === ESC) { + // Selective control sequences (look them up) - we can just skip the + // question mark. + if (text[charI] === '?') { charI++ + } - if (text[charI] !== '[') { - throw new Error('ESC not followed by [') - } - + const args = [] + let val = '' + while (isDigit(text[charI])) { + val += text[charI] charI++ - // Selective control sequences (look them up) - we can just skip the - // question mark. - if (text[charI] === '?') { + if (text[charI] === ';') { charI++ + args.push(val) + val = '' + continue } + } + args.push(val) - const args = [] - let val = '' - while (isDigit(text[charI])) { - val += text[charI] - charI++ + // CUP - Cursor Position (moveCursor) + if (text[charI] === 'H') { + cursorRow = parseInt(args[0]) + cursorCol = parseInt(args[1]) + } - if (text[charI] === ';') { - charI++ - args.push(val) - val = '' - continue - } + // SM - Set Mode + if (text[charI] === 'h') { + if (args[0] === '25') { + showCursor = true } - args.push(val) + } - // CUP - Cursor Position (moveCursor) - if (text[charI] === 'H') { - cursorRow = parseInt(args[0]) - cursorCol = parseInt(args[1]) + // ED - Erase Display (clearScreen) + if (text[charI] === 'J') { + // ESC[2J - erase whole display + if (args[0] === '2') { + chars.fill(blank) + charI += 3 + cursorCol = 1 + cursorRow = 1 } - // SM - Set Mode - if (text[charI] === 'h') { - if (args[0] === '25') { - showCursor = true + // ESC[1J - erase to beginning + else if (args[0] === '1') { + for (let i = 0; i < cursorIndex; i++) { + chars[i * 2] = ' ' + chars[i * 2 + 1] = [] } } - // ED - Erase Display (clearScreen) - if (text[charI] === 'J') { - // ESC[2J - erase whole display - if (args[0] === '2') { - chars.fill(blank) - charI += 3 - cursorCol = 1 - cursorRow = 1 - } - - // ESC[1J - erase to beginning - else if (args[0] === '1') { - for (let i = 0; i < cursorIndex; i++) { - chars[i * 2] = ' ' - chars[i * 2 + 1] = [] - } + // ESC[0J - erase to end + else if (args.length === 0 || args[0] === '0') { + for (let i = cursorIndex; i < chars.length; i++) { + chars[i * 2] = ' ' + chars[i * 2 + 1] = [] } + } + } - // ESC[0J - erase to end - else if (args.length === 0 || args[0] === '0') { - for (let i = cursorIndex; i < chars.length; i++) { - chars[i * 2] = ' ' - chars[i * 2 + 1] = [] - } - } + // RM - Reset Mode + if (text[charI] === 'l') { + if (args[0] === '25') { + showCursor = false } + } - // RM - Reset Mode - if (text[charI] === 'l') { - if (args[0] === '25') { - showCursor = false + // SGR - Select Graphic Rendition + if (text[charI] === 'm') { + const removeAttribute = attr => { + if (attributes.includes(attr)) { + attributes = attributes.slice() + attributes.splice(attributes.indexOf(attr), 1) } } - // SGR - Select Graphic Rendition - if (text[charI] === 'm') { - const removeAttribute = attr => { - if (attributes.includes(attr)) { - attributes = attributes.slice() - attributes.splice(attributes.indexOf(attr), 1) + for (const arg of args) { + if (arg === '0') { + attributes = [] + } else if (arg === '22') { // Neither bold nor faint + removeAttribute('1') + removeAttribute('2') + } else if (arg === '23') { // Neither italic nor Fraktur + removeAttribute('3') + removeAttribute('20') + } else if (arg === '24') { // Not underlined + removeAttribute('4') + } else if (arg === '25') { // Blink off + removeAttribute('5') + } else if (arg === '27') { // Inverse off + removeAttribute('7') + } else if (arg === '28') { // Conceal off + removeAttribute('8') + } else if (arg === '29') { // Not crossed out + removeAttribute('9') + } else if (arg === '39') { // Default foreground + for (let i = 0; i < 10; i++) { + removeAttribute('3' + i) } - } - - for (const arg of args) { - if (arg === '0') { - attributes = [] - } else if (arg === '22') { // Neither bold nor faint - removeAttribute('1') - removeAttribute('2') - } else if (arg === '23') { // Neither italic nor Fraktur - removeAttribute('3') - removeAttribute('20') - } else if (arg === '24') { // Not underlined - removeAttribute('4') - } else if (arg === '25') { // Blink off - removeAttribute('5') - } else if (arg === '27') { // Inverse off - removeAttribute('7') - } else if (arg === '28') { // Conceal off - removeAttribute('8') - } else if (arg === '29') { // Not crossed out - removeAttribute('9') - } else if (arg === '39') { // Default foreground - for (let i = 0; i < 10; i++) { - removeAttribute('3' + i) - } - } else if (arg === '49') { // Default background - for (let i = 0; i < 10; i++) { - removeAttribute('4' + i) - } - } else { - attributes = attributes.concat([arg]) + } else if (arg === '49') { // Default background + for (let i = 0; i < 10; i++) { + removeAttribute('4' + i) } + } else { + attributes = attributes.concat([arg]) } } - - continue } - chars[cursorIndex] = { - char: text[charI], attributes - } + continue + } - // Some characters take up multiple columns, e.g. Japanese text. Take - // this into consideration when drawing. - const charColumns = wcwidth(text[charI]) - cursorCol += charColumns + chars[cursorIndex] = { + char: text[charI], attributes + } - // If the character takes up 2+ columns, treat columns past the first - // one (where the character is) as empty. (Note this is different from - // "blank", which represents an empty space character ' '.) - for (let i = 1; i < charColumns; i++) { - chars[cursorIndex + i] = {char: '', attributes: []} - } + // Some characters take up multiple columns, e.g. Japanese text. Take + // this into consideration when drawing. + const charColumns = wcwidth(text[charI]) + cursorCol += charColumns - if (cursorCol > scrCols) { - cursorCol = 1 - cursorRow++ - } + // If the character takes up 2+ columns, treat columns past the first + // one (where the character is) as empty. (Note this is different from + // "blank", which represents an empty space character ' '.) + for (let i = 1; i < charColumns; i++) { + chars[cursorIndex + i] = {char: '', attributes: []} } - // SPOooooOOoky diffing! ------------- - // - // - Search for series of differences. This means a collection of characters - // which have different text or attribute properties. - // - // - Figure out how to print these differences. Move the cursor to the beginning - // character's row/column, then print the differences. + if (cursorCol > scrCols) { + cursorCol = 1 + cursorRow++ + } + } - const newChars = chars + // SPOooooOOoky diffing! ------------- + // + // - Search for series of differences. This means a collection of characters + // which have different text or attribute properties. + // + // - Figure out how to print these differences. Move the cursor to the beginning + // character's row/column, then print the differences. + + const newChars = chars + + const differences = [] + + if (oldChars === null) { + differences.push(0) + differences.push(newChars.slice()) + } else { + const charsEqual = (oldChar, newChar) => { + if (oldChar.char !== newChar.char) { + return false + } - const differences = [] + let oldAttrs = oldChar.attributes.slice() + let newAttrs = newChar.attributes.slice() - if (oldChars === null) { - differences.push(0) - differences.push(newChars.slice()) - } else { - const charsEqual = (oldChar, newChar) => { - if (oldChar.char !== newChar.char) { + while (newAttrs.length) { + const attr = newAttrs.shift() + if (oldAttrs.includes(attr)) { + oldAttrs.splice(oldAttrs.indexOf(attr), 1) + } else { return false } + } - let oldAttrs = oldChar.attributes.slice() - let newAttrs = newChar.attributes.slice() - - while (newAttrs.length) { - const attr = newAttrs.shift() - if (oldAttrs.includes(attr)) { - oldAttrs.splice(oldAttrs.indexOf(attr), 1) - } else { - return false - } - } - - oldAttrs = oldChar.attributes.slice() - newAttrs = newChar.attributes.slice() + oldAttrs = oldChar.attributes.slice() + newAttrs = newChar.attributes.slice() - while (oldAttrs.length) { - const attr = oldAttrs.shift() - if (newAttrs.includes(attr)) { - newAttrs.splice(newAttrs.indexOf(attr), 1) - } else { - return false - } + while (oldAttrs.length) { + const attr = oldAttrs.shift() + if (newAttrs.includes(attr)) { + newAttrs.splice(newAttrs.indexOf(attr), 1) + } else { + return false } - - return true } - let curChars = null + return true + } - for (let i = 0; i < chars.length; i++) { - const oldChar = oldChars[i] - const newChar = newChars[i] + let curChars = null - // TODO: Some sort of "distance" before we should clear curDiff? - // It may take *less* characters if this diff and the next are merged - // (entering a single character is smaller than the length of the code - // used to move past that character). Probably not very significant of - // an impact, though. - if (charsEqual(oldChar, newChar)) { - curChars = null - } else { - if (curChars === null) { - curChars = [] - differences.push(i, curChars) - } + for (let i = 0; i < chars.length; i++) { + const oldChar = oldChars[i] + const newChar = newChars[i] - curChars.push(newChar) + // TODO: Some sort of "distance" before we should clear curDiff? + // It may take *less* characters if this diff and the next are merged + // (entering a single character is smaller than the length of the code + // used to move past that character). Probably not very significant of + // an impact, though. + if (charsEqual(oldChar, newChar)) { + curChars = null + } else { + if (curChars === null) { + curChars = [] + differences.push(i, curChars) } + + curChars.push(newChar) } } + } - // Character concatenation ----------- + // Character concatenation ----------- - let lastChar = oldLastChar || { - char: '', - attributes: [] - } + let lastChar = oldLastChar || { + char: '', + attributes: [] + } - const result = [] - - for (let parse = 0; parse < differences.length; parse += 2) { - const i = differences[parse] - const chars = differences[parse + 1] - - const col = i % scrCols - const row = (i - col) / scrCols - result.push(ansi.moveCursor(row, col)) - - for (const char of chars) { - const newAttributes = ( - char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) - ) - - const removedAttributes = ( - lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) - ) - - // The only way to practically remove any character attribute is to - // reset all of its attributes and then re-add its existing attributes. - // If we do that, there's no need to add new attributes. - if (removedAttributes.length) { - result.push(ansi.resetAttributes()) - result.push(`${ESC}[${char.attributes.join(';')}m`) - } else if (newAttributes.length) { - result.push(`${ESC}[${newAttributes.join(';')}m`) - } + const result = [] + + for (let parse = 0; parse < differences.length; parse += 2) { + const i = differences[parse] + const chars = differences[parse + 1] + + const col = i % scrCols + const row = (i - col) / scrCols + result.push(moveCursor(row, col)) + + for (const char of chars) { + const newAttributes = ( + char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) + ) + + const removedAttributes = ( + lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) + ) + + // The only way to practically remove any character attribute is to + // reset all of its attributes and then re-add its existing attributes. + // If we do that, there's no need to add new attributes. + if (removedAttributes.length) { + result.push(resetAttributes()) + result.push(`${ESC}[${char.attributes.join(';')}m`) + } else if (newAttributes.length) { + result.push(`${ESC}[${newAttributes.join(';')}m`) + } - result.push(char.char) + result.push(char.char) - lastChar = char - } + lastChar = char } + } - // If anything changed *or* the cursor moved, we need to put it back where - // it was before: - if (result.length || cursorCol !== oldCursorCol || cursorRow !== oldCursorRow) { - result.push(ansi.moveCursor(cursorRow, cursorCol)) - } + // If anything changed *or* the cursor moved, we need to put it back where + // it was before: + if (result.length || cursorCol !== oldCursorCol || cursorRow !== oldCursorRow) { + result.push(moveCursor(cursorRow, cursorCol)) + } - // If the cursor is visible and wasn't before, or vice versa, we need to - // show that: - if (showCursor && !oldShowCursor) { - result.push(ansi.showCursor()) - } else if (!showCursor && oldShowCursor) { - result.push(ansi.hideCursor()) - } + // If the cursor is visible and wasn't before, or vice versa, we need to + // show that: + if (showCursor && !oldShowCursor) { + result.push(showCursor()) + } else if (!showCursor && oldShowCursor) { + result.push(hideCursor()) + } - return { - oldChars: newChars.slice(), - oldLastChar: Object.assign({}, lastChar), - oldScrRows: scrRows, - oldScrCols: scrCols, - oldCursorRow: cursorRow, - oldCursorCol: cursorCol, - oldShowCursor: showCursor, - screen: result.join('') - } + return { + oldChars: newChars.slice(), + oldLastChar: Object.assign({}, lastChar), + oldScrRows: scrRows, + oldScrCols: scrCols, + oldCursorRow: cursorRow, + oldCursorCol: cursorCol, + oldShowCursor: showCursor, + screen: result.join('') } } - -module.exports = ansi diff --git a/util/count.js b/util/count.js index 24c11b0..d4c0919 100644 --- a/util/count.js +++ b/util/count.js @@ -1,4 +1,4 @@ -module.exports = function count(arr) { +export default function count(arr) { // Counts the number of times the items of an array appear (only on the top // level; it doesn't search through nested arrays!). Returns a map of // item -> count. diff --git a/util/exception.js b/util/exception.js index e88ff99..1271b6a 100644 --- a/util/exception.js +++ b/util/exception.js @@ -1,4 +1,4 @@ -module.exports = function exception(code, message) { +export default function exception(code, message) { // Makes a custom error with the given code and message. const err = new Error(`${code}: ${message}`) diff --git a/util/index.js b/util/index.js new file mode 100644 index 0000000..db3d8a7 --- /dev/null +++ b/util/index.js @@ -0,0 +1,11 @@ +export * as ansi from './ansi.js' +export * as interfaces from './interfaces/index.js' + +export {default as count} from './count.js' +export {default as exception} from './exception.js' +export {default as smoothen} from './smoothen.js' +export {default as telchars} from './telchars.js' +export {default as tuiApp} from './tui-app.js' +export {default as unichars} from './unichars.js' +export {default as waitForData} from './waitForData.js' +export {default as wrap} from './wrap.js' diff --git a/util/interfaces/CommandLineInterface.js b/util/interfaces/CommandLineInterface.js new file mode 100644 index 0000000..66c8c43 --- /dev/null +++ b/util/interfaces/CommandLineInterface.js @@ -0,0 +1,92 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class CommandLineInterface extends EventEmitter { + constructor(inStream = process.stdin, outStream = process.stdout, proc = process) { + super() + + this.inStream = inStream + this.outStream = outStream + this.process = proc + + inStream.on('data', buffer => { + this.emit('inputData', buffer) + }) + + inStream.setRawMode(true) + + proc.on('SIGWINCH', async buffer => { + this.emit('resize', await this.getScreenSize()) + }) + } + + async getScreenSize() { + const waitUntil = cond => waitForData(this.inStream, cond) + + // Get old cursor position.. + this.outStream.write(ansi.requestCursorPosition()) + const { options: oldCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Move far to the bottom right of the screen, then get cursor position.. + // (We could use moveCursor here, but the 0-index offset isn't really + // relevant.) + this.outStream.write(ansi.moveCursorRaw(9999, 9999)) + this.outStream.write(ansi.requestCursorPosition()) + const { options: sizeCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Restore to old cursor position.. (Using moveCursorRaw is actaully + // necessary here, since we'll be passing the coordinates returned from + // another ANSI command.) + this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) + + // And return dimensions. + const [ sizeLine, sizeCol ] = sizeCoords + return { + lines: sizeLine, cols: sizeCol, + width: sizeCol, height: sizeLine + } + } + + parseANSICommand(buffer) { + // Typically ANSI commands are written ESC[1;2;3;4C + // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and + // decimal 33 + // ..where [ and ; are the literal strings "[" and ";" + // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII + // that may last more than one byte (e.g. "15") + // ..where C is some number representing the code of the command + + if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { + throw new Error('Not an ANSI command') + } + + const options = [] + let curOption = '' + let commandCode = null + for (const val of buffer.slice(2)) { + if (48 <= val && val <= 57) { // 0124356789 + curOption = curOption.concat(val - 48) + } else { + options.push(parseInt(curOption)) + curOption = '' + + if (val !== 59) { // ; + commandCode = val + break + } + } + } + + return {code: commandCode, options: options} + } + + write(data) { + this.outStream.write(data) + } +} diff --git a/util/interfaces/Flushable.js b/util/interfaces/Flushable.js new file mode 100644 index 0000000..d8b72d3 --- /dev/null +++ b/util/interfaces/Flushable.js @@ -0,0 +1,126 @@ +import * as ansi from '../ansi.js' +import unic from '../unichars.js' + +export default class Flushable { + // A writable that can be used to collect chunks of data before writing + // them. + + constructor(writable, shouldCompress = false) { + this.target = writable + + // Use the magical ANSI self-made compression method that probably + // doesn't *quite* work but should drastically decrease write size? + this.shouldCompress = shouldCompress + + // Whether or not to show compression statistics (original written size + // and ANSI-interpreted compressed size) in the output of flush. + this.shouldShowCompressionStatistics = false + + // Use resizeScreen if you plan on using the ANSI compressor! + this.screenLines = 24 + this.screenCols = 80 + this.lastFrame = undefined + + this.ended = false + this.paused = false + this.requestedFlush = false + + this.chunks = [] + } + + resizeScreen({lines, cols}) { + this.screenLines = lines + this.screenCols = cols + this.clearLastFrame() + } + + clearLastFrame() { + this.lastFrame = undefined + } + + write(what) { + this.chunks.push(what) + } + + flush() { + // If we're paused, we don't want to write, but we will keep a note that a + // flush was requested for when we unpause. + if (this.paused) { + this.requestedFlush = true + return + } + + // Don't write if we've ended. + if (this.ended) { + return + } + + // End if the target is destroyed. + // Yes, this relies on the target having a destroyed property + // Don't worry, it'll still work if there is no destroyed property though + // (I think) + if (this.target.destroyed) { + this.end() + return + } + + let toWrite = this.chunks.join('') + + if (this.shouldCompress) { + toWrite = this.compress(toWrite) + } + + try { + this.target.write(toWrite) + } catch(err) { + console.error('Flushable write error (ending):', err.message) + this.end() + } + + this.chunks = [] + } + + pause() { + this.paused = true + } + + resume() { + this.paused = false + + if (this.requestedFlush) { + this.flush() + } + } + + end() { + this.ended = true + } + + compress(toWrite) { + // TODO: customize screen size + const output = ansi.interpret( + toWrite, this.screenLines, this.screenCols, this.lastFrame + ) + + let { screen } = output + + this.lastFrame = output + + if (this.shouldShowCompressionStatistics) { + let msg = this.lastInterpretMessage + if (screen.length > 0 || !this.lastInterpretMessage) { + const pcSaved = Math.round(1000 - (1000 / toWrite.length * screen.length)) / 10 + const kbSaved = Math.round((toWrite.length - screen.length) / 100) / 10 + msg = this.lastInterpretMessage = ( + '(ANSI-interpret: ' + + `${toWrite.length} -> ${screen.length} ${pcSaved}% / ${kbSaved} KB saved)` + ) + } + screen += '\x1b[H\x1b[0m' + screen += msg + unic.BOX_H_DOUBLE.repeat(this.screenCols - msg.length) + this.lastFrame.oldLastChar.attributes = [] + } + + return screen + } +} diff --git a/util/interfaces/TelnetInterface.js b/util/interfaces/TelnetInterface.js new file mode 100644 index 0000000..8777680 --- /dev/null +++ b/util/interfaces/TelnetInterface.js @@ -0,0 +1,139 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class TelnetInterface extends EventEmitter { + constructor(socket) { + super() + + this.socket = socket + + socket.on('data', buffer => { + if (buffer[0] === 255) { + this.handleTelnetData(buffer) + } else { + this.emit('inputData', buffer) + } + }) + + this.initTelnetOptions() + } + + initTelnetOptions() { + // Initializes various socket options, using telnet magic. + + // Disables linemode. + this.socket.write(Buffer.from([ + 255, 253, 34, // IAC DO LINEMODE + 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE + 255, 251, 1 // IAC WILL ECHO + ])) + + // Will SGA. Helps with putty apparently. + this.socket.write(Buffer.from([ + 255, 251, 3 // IAC WILL SGA + ])) + + this.socket.write(ansi.hideCursor()) + } + + cleanTelnetOptions() { + // Resets the telnet options and magic set in initTelnetOptions. + + this.socket.write(ansi.resetAttributes()) + this.socket.write(ansi.showCursor()) + } + + async getScreenSize() { + this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS + + let didWillNAWS = false + let didSBNAWS = false + let sb + + inputLoop: while (true) { + const data = await waitForData(this.socket) + + for (const command of this.parseTelnetCommands(data)) { + // WILL NAWS + if (command[1] === 251 && command[2] === 31) { + didWillNAWS = true + continue + } + + // SB NAWS + if (didWillNAWS && command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sb = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + break inputLoop + } + } + } + + return this.parseSBNAWS(sb) + } + + parseTelnetCommands(buffer) { + if (buffer[0] === 255) { + // Telnet IAC (Is A Command) - ignore + + // Split the data into multiple IAC commands if more than one IAC was + // sent. + const values = Array.from(buffer.values()) + const commands = [] + const curCmd = [255] + for (const value of values) { + if (value === 255) { // IAC + commands.push(Array.from(curCmd)) + curCmd.splice(1, curCmd.length) + continue + } + curCmd.push(value) + } + commands.push(curCmd) + + return commands + } else { + return [] + } + } + + write(data) { + this.socket.write(data) + } + + handleTelnetData(buffer) { + let didSBNAWS = false + let sbNAWS + + for (let command of this.parseTelnetCommands(buffer)) { + // SB NAWS + if (command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sbNAWS = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + didSBNAWS = false + this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) + this.emit('resize', this.parseSBNAWS(sbNAWS)) + continue + } + } + } + + parseSBNAWS(sb) { + const cols = (sb[0] << 8) + sb[1] + const lines = (sb[2] << 8) + sb[3] + + return { cols, lines, width: cols, height: lines } + } +} diff --git a/util/interfaces/index.js b/util/interfaces/index.js new file mode 100644 index 0000000..83aeb2c --- /dev/null +++ b/util/interfaces/index.js @@ -0,0 +1,4 @@ +export {default as Flushable} from './Flushable.js' + +export {default as CommandLineInterface} from './CommandLineInterface.js' +export {default as TelnetInterface} from './TelnetInterface.js' diff --git a/util/smoothen.js b/util/smoothen.js index 55ba23c..5809271 100644 --- a/util/smoothen.js +++ b/util/smoothen.js @@ -1,4 +1,4 @@ -module.exports = function(tx, x, divisor) { +export default function smoothen(tx, x, divisor) { // Smoothly transitions givens X to TX using a given divisor. Rounds the // amount moved. diff --git a/util/telchars.js b/util/telchars.js index 12d4095..5a5ad42 100644 --- a/util/telchars.js +++ b/util/telchars.js @@ -97,4 +97,4 @@ const telchars = { isCharacter: (buf, char) => compareBufStr(buf, char), } -module.exports = telchars +export default telchars diff --git a/util/tui-app.js b/util/tui-app.js index a695e57..2f09818 100644 --- a/util/tui-app.js +++ b/util/tui-app.js @@ -2,27 +2,26 @@ // program. Contained to reduce boilerplate and improve consistency between // programs. -const ansi = require('./ansi'); +import {Root} from 'tui-lib/ui/primitives' -const CommandLineInterfacer = require('./CommandLineInterfacer'); -const Flushable = require('./Flushable'); -const Root = require('../ui/Root'); +import {CommandLineInterface, Flushable} from './interfaces/index.js' +import * as ansi from './ansi.js' -module.exports = async function tuiApp(callback) { - // TODO: Support other interfacers. - const interfacer = new CommandLineInterfacer(); +export default async function tuiApp(callback) { + // TODO: Support other screen interfaces. + const screenInterface = new CommandLineInterface(); const flushable = new Flushable(process.stdout, true); - const root = new Root(interfacer); + const root = new Root(screenInterface); - const size = await interfacer.getScreenSize(); + const size = await screenInterface.getScreenSize(); root.w = size.width; root.h = size.height; flushable.resizeScreen(size); root.on('rendered', () => flushable.flush()); - interfacer.on('resize', newSize => { + screenInterface.on('resize', newSize => { root.w = newSize.width; root.h = newSize.height; flushable.resizeScreen(newSize); diff --git a/util/unichars.js b/util/unichars.js index ee137e8..2099b62 100644 --- a/util/unichars.js +++ b/util/unichars.js @@ -1,6 +1,6 @@ // Useful Unicode characters. -module.exports = { +export default { /* … */ ELLIPSIS: '\u2026', /* ─ */ BOX_H: '\u2500', diff --git a/util/waitForData.js b/util/waitForData.js index bf40c52..f8d4a92 100644 --- a/util/waitForData.js +++ b/util/waitForData.js @@ -1,4 +1,4 @@ -module.exports = function waitForData(stream, cond = null) { +export default function waitForData(stream, cond = null) { return new Promise(resolve => { stream.on('data', data => { if (cond ? cond(data) : true) { diff --git a/util/wrap.js b/util/wrap.js index 3c381d4..2c720c8 100644 --- a/util/wrap.js +++ b/util/wrap.js @@ -1,4 +1,4 @@ -module.exports = function wrap(str, width) { +export default function wrap(str, width) { // Wraps a string into separate lines. Returns an array of strings, for // each line of the text. -- cgit 1.3.0-6-gf8a5