« 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/interfaces
diff options
context:
space:
mode:
Diffstat (limited to 'util/interfaces')
-rw-r--r--util/interfaces/CommandLineInterface.js92
-rw-r--r--util/interfaces/Flushable.js126
-rw-r--r--util/interfaces/TelnetInterface.js139
-rw-r--r--util/interfaces/index.js4
4 files changed, 361 insertions, 0 deletions
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'