« get me outta code hell

tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/util
diff options
context:
space:
mode:
Diffstat (limited to 'util')
-rw-r--r--util/CommandLineInterfacer.js86
-rw-r--r--util/Flushable.js97
-rw-r--r--util/TelnetInterfacer.js137
-rw-r--r--util/ansi.js272
-rw-r--r--util/exception.js7
-rw-r--r--util/telchars.js30
-rw-r--r--util/unichars.js17
-rw-r--r--util/waitForData.js9
8 files changed, 655 insertions, 0 deletions
diff --git a/util/CommandLineInterfacer.js b/util/CommandLineInterfacer.js
new file mode 100644
index 0000000..3f9d208
--- /dev/null
+++ b/util/CommandLineInterfacer.js
@@ -0,0 +1,86 @@
+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) {
+    super()
+
+    this.inStream = inStream
+    this.outStream = outStream
+
+    inStream.on('data', buffer => {
+      this.emit('inputData', buffer)
+    })
+
+    inStream.setRawMode(true)
+  }
+
+  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 (let 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
new file mode 100644
index 0000000..b031677
--- /dev/null
+++ b/util/Flushable.js
@@ -0,0 +1,97 @@
+const ansi = require('./ansi')
+
+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
+
+    // Update these if you plan on using the ANSI compressor!
+    this.screenLines = 24
+    this.screenCols = 80
+
+    this.ended = false
+    this.paused = false
+    this.requestedFlush = false
+
+    this.chunks = []
+  }
+
+  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 screen = ansi.interpret(toWrite, this.screenLines, this.screenCols)
+
+    /*
+    const pcSaved = Math.round(100 - (100 / toWrite.length * screen.length))
+    console.log(
+      '\x1b[1A' +
+      `${toWrite.length} - ${screen.length} ${pcSaved}% saved   `
+    )
+    */
+
+    return screen
+  }
+}
diff --git a/util/TelnetInterfacer.js b/util/TelnetInterfacer.js
new file mode 100644
index 0000000..f9b1c23
--- /dev/null
+++ b/util/TelnetInterfacer.js
@@ -0,0 +1,137 @@
+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 (let 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 (let 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))
+        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
new file mode 100644
index 0000000..0e6e3fe
--- /dev/null
+++ b/util/ansi.js
@@ -0,0 +1,272 @@
+const ESC = '\x1b'
+
+const isDigit = char => '0123456789'.indexOf(char) >= 0
+
+const ansi = {
+  ESC,
+
+  // 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,
+
+  clearScreen() {
+    // Clears the screen, removing any characters displayed, and resets the
+    // cursor position.
+
+    return `${ESC}[2J`
+  },
+
+  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.
+
+    return `${ESC}[${line};${col}H`
+  },
+
+  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}[${line + 1};${col + 1}H`
+  },
+
+  hideCursor() {
+    // Makes the cursor invisible.
+
+    return `${ESC}[?25l`
+  },
+
+  showCursor() {
+    // Makes the cursor visible.
+
+    return `${ESC}[?25h`
+  },
+
+  resetAttributes() {
+    // Resets all attributes, including text decorations, foreground and
+    // background color.
+
+    return `${ESC}[0m`
+  },
+
+  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}[${attrs.join(';')}m`
+  },
+
+  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 ''
+    }
+
+    return ansi.setAttributes([color])
+  },
+
+  invert() {
+    // Inverts the foreground and background colors.
+
+    return `${ESC}[7m`
+  },
+
+  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}[6n`
+  },
+
+  isANSICommand(buffer, code = null) {
+    return (
+      buffer[0] === 0x1b && buffer[1] === 0x5b &&
+      (code ? buffer[buffer.length - 1] === code : true)
+    )
+  },
+
+
+  interpret(text, scrRows, scrCols) {
+    // Interprets the given ansi code, more or less.
+
+    const blank = {
+      attributes: [],
+      char: ' '
+    }
+
+    const chars = new Array(scrRows * scrCols).fill(blank)
+
+    let cursorRow = 1
+    let cursorCol = 1
+    const attributes = []
+    const getCursorIndex = () => (cursorRow - 1) * scrCols + (cursorCol - 1)
+
+    for (let charI = 0; charI < text.length; charI++) {
+      if (text[charI] === ESC) {
+        charI++
+
+        if (text[charI] !== '[') {
+          throw new Error('ESC not followed by [')
+        }
+
+        charI++
+
+        const args = []
+        let val = ''
+        while (isDigit(text[charI])) {
+          val += text[charI]
+          charI++
+
+          if (text[charI] === ';') {
+            charI++
+            args.push(val)
+            val = ''
+            continue
+          }
+        }
+        args.push(val)
+
+        // CUP - Cursor Position (moveCursor)
+        if (text[charI] === 'H') {
+          cursorRow = args[0]
+          cursorCol = 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
+          }
+
+          // ESC[1J - erase to beginning
+          else if (args[0] === '1') {
+            for (let i = 0; i < getCursorIndex(); i++) {
+              chars[i] = blank
+            }
+          }
+
+          // ESC[0J - erase to end
+          else if (args.length === 0 || args[0] === '0') {
+            for (let i = getCursorIndex(); i < chars.length; i++) {
+              chars[i] = blank
+            }
+          }
+        }
+
+        // SGR - Select Graphic Rendition
+        if (text[charI] === 'm') {
+          for (let arg of args) {
+            if (arg === '0') {
+              attributes.splice(0, attributes.length)
+            } else {
+              attributes.push(arg)
+            }
+          }
+        }
+
+        continue
+      }
+
+      // debug
+      /*
+      if (text[charI] === '.') {
+        console.log(
+          `#1-char "${text[charI]}" at ` +
+          `(${cursorRow},${cursorCol}):${getCursorIndex()} ` +
+          ` attr:[${attributes.join(';')}]`
+        )
+      }
+      */
+
+      chars[getCursorIndex()] = {
+        char: text[charI],
+        attributes: attributes.slice()
+      }
+
+      cursorCol++
+
+      if (cursorCol > scrCols) {
+        cursorCol = 1
+        cursorRow++
+      }
+    }
+
+    // Character concatenation -----------
+
+    // Move to the top left of the screen initially.
+    const result = [ ansi.moveCursorRaw(1, 1) ]
+
+    let lastChar = {
+      char: '',
+      attributes: []
+    }
+
+    //let n = 1 // debug
+
+    for (let 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) {
+        // console.log(
+        //   `removed some attributes "${char.char}"`, removedAttributes
+        // )
+        result.push(ansi.resetAttributes())
+        result.push(`${ESC}[${char.attributes.join(';')}m`)
+      } else if (newAttributes.length) {
+        result.push(`${ESC}[${newAttributes.join(';')}m`)
+      }
+
+      // debug
+      /*
+      if (char.char !== ' ') {
+        console.log(
+          `#2-char ${char.char}; ${chars.indexOf(char) - n} inbetween`
+        )
+        n = chars.indexOf(char)
+      }
+      */
+
+      result.push(char.char)
+
+      lastChar = char
+    }
+
+    return result.join('')
+  }
+}
+
+module.exports = ansi
diff --git a/util/exception.js b/util/exception.js
new file mode 100644
index 0000000..e88ff99
--- /dev/null
+++ b/util/exception.js
@@ -0,0 +1,7 @@
+module.exports = function exception(code, message) {
+  // Makes a custom error with the given code and message.
+
+  const err = new Error(`${code}: ${message}`)
+  err.code = code
+  return err
+}
diff --git a/util/telchars.js b/util/telchars.js
new file mode 100644
index 0000000..8cf414c
--- /dev/null
+++ b/util/telchars.js
@@ -0,0 +1,30 @@
+// Useful telnet key detection.
+
+const telchars = {
+  isSpace: buf => buf[0] === 0x20,
+  isEnter: buf => buf[0] === 0x0d && buf[1] === 0x00,
+  isTab: buf => buf[0] === 0x09,
+  isBackTab: buf => buf[0] === 0x1b && buf[2] === 0x5A,
+
+  // isEscape is hard because it's just send as ESC (the ANSI escape code),
+  // so we need to make sure that the escape code is all on its own
+  // (i.e. the length is 1)
+  isEscape: buf => buf[0] === 0x1b && buf.length === 1,
+
+  // Use this for when you'd like to detect the user confirming or issuing a
+  // command, like the X button on your PlayStation controller, or the mouse
+  // when you click on a button.
+  isSelect: buf => telchars.isSpace(buf) || telchars.isEnter(buf),
+
+  // Use this for when you'd like to detect the user cancelling an action,
+  // like the O button on your PlayStation controller, or the Escape key on
+  // your keyboard.
+  isCancel: buf => telchars.isEscape(buf),
+
+  isUp: buf => buf[0] === 0x1b && buf[2] === 0x41,
+  isDown: buf => buf[0] === 0x1b && buf[2] === 0x42,
+  isRight: buf => buf[0] === 0x1b && buf[2] === 0x43,
+  isLeft: buf => buf[0] === 0x1b && buf[2] === 0x44,
+}
+
+module.exports = telchars
diff --git a/util/unichars.js b/util/unichars.js
new file mode 100644
index 0000000..d685890
--- /dev/null
+++ b/util/unichars.js
@@ -0,0 +1,17 @@
+// Useful Unicode characters.
+
+module.exports = {
+  /* … */ ELLIPSIS: '\u2026',
+
+  /* ─ */ BOX_H: '\u2500',
+  /* ━ */ BOX_H_THICK: '\u2501',
+  /* ═ */ BOX_H_DOUBLE: '\u2550',
+  /* │ */ BOX_V: '\u2502',
+  /* ┃ */ BOX_V_THICK: '\u2503',
+  /* ║ */ BOX_V_DOUBLE: '\u2551',
+
+  /* ┐ */ BOX_CORNER_TR: '\u2510',
+  /* └ */ BOX_CORNER_BL: '\u2514',
+  /* ┘ */ BOX_CORNER_BR: '\u2518',
+  /* ┌ */ BOX_CORNER_TL: '\u250C'
+}
diff --git a/util/waitForData.js b/util/waitForData.js
new file mode 100644
index 0000000..bf40c52
--- /dev/null
+++ b/util/waitForData.js
@@ -0,0 +1,9 @@
+module.exports = function waitForData(stream, cond = null) {
+  return new Promise(resolve => {
+    stream.on('data', data => {
+      if (cond ? cond(data) : true) {
+        resolve(data)
+      }
+    })
+  })
+}