« 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/ansi.js
diff options
context:
space:
mode:
Diffstat (limited to 'util/ansi.js')
-rw-r--r--util/ansi.js272
1 files changed, 272 insertions, 0 deletions
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