« get me outta code hell

tui-text-editor - Embeddable tui-lib text editor
summary refs log tree commit diff
path: root/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'index.js')
-rw-r--r--index.js380
1 files changed, 380 insertions, 0 deletions
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..7c652bc
--- /dev/null
+++ b/index.js
@@ -0,0 +1,380 @@
+const {
+  ui: {
+    form: {
+      FocusElement
+    }
+  },
+  util: {
+    ansi,
+    telchars: telc
+  }
+} = require('tui-lib')
+
+class TuiTextEditor extends FocusElement {
+  constructor() {
+    super()
+
+    this.cursorSourceLine = 0
+    this.cursorSourceCol = 0
+    this.cursorVisible = true
+    this.idealCursorSourceCol = 0
+
+    this.sourceLines = []
+    this.uiLines = []
+
+    this.rebuildUiLines()
+
+    this.eraseWordBoundary = ' .*!@#$%^&*()-=+[]{}\\|;:,.<>/?`~'
+    this.movementWordBoundary = ' '
+  }
+
+  fixLayout() {
+    this.rebuildUiLines()
+  }
+
+  rebuildUiLines() {
+    this.uiLines = []
+
+    const { sourceLines } = this
+    if (sourceLines.length === 0) {
+      sourceLines.push({text: ''})
+    }
+
+    for (const line of sourceLines) {
+      this.uiLines.push(...this.wrapLine(line))
+    }
+  }
+
+  keyPressed(keyBuf) {
+    let clearEscape = true
+
+    if (telc.isDown(keyBuf)) {
+      this.cursorSourceLine++
+      if (this.cursorSourceLine >= this.sourceLines.length) {
+        this.cursorSourceLine--
+      } else {
+        this.cursorSourceCol = Math.min(this.idealCursorSourceCol, this.sourceLines[this.cursorSourceLine].text.length)
+      }
+    } else if (telc.isUp(keyBuf)) {
+      this.cursorSourceLine--
+      if (this.cursorSourceLine < 0) {
+        this.cursorSourceLine++
+      } else {
+        this.cursorSourceCol = Math.min(this.idealCursorSourceCol, this.sourceLines[this.cursorSourceLine].text.length)
+      }
+    } else if (telc.isLeft(keyBuf)) {
+      this.cursorSourceCol--
+      if (this.cursorSourceCol < 0) {
+        if (this.cursorSourceLine > 0) {
+          this.cursorSourceLine--
+          this.cursorSourceCol = this.sourceLines[this.cursorSourceLine].text.length
+        } else {
+          this.cursorSourceCol++
+        }
+      }
+      this.idealCursorSourceCol = this.cursorSourceCol
+    } else if (telc.isRight(keyBuf)) {
+      this.cursorSourceCol++
+      if (this.cursorSourceCol > this.sourceLines[this.cursorSourceLine].text.length) {
+        if (this.cursorSourceLine + 1 < this.sourceLines.length) {
+          this.cursorSourceLine++
+          this.cursorSourceCol = 0
+        } else {
+          this.cursorSourceCol--
+        }
+      }
+      this.idealCursorSourceCol = this.cursorSourceCol
+    } else if (telc.isControlLeft(keyBuf)) {
+      this.moveCursorTo(this.getStartOfWord(this.movementWordBoundary, this.cursorPosition))
+    } else if (telc.isControlRight(keyBuf)) {
+      this.moveCursorTo(this.getEndOfWord(this.movementWordBoundary, this.cursorPosition))
+    } else if (keyBuf[0] === 12) { // ^L
+      this.rebuildUiLines()
+    } else if (keyBuf[0] === 1) { // ^A
+      this.moveCursorTo(this.getStartOfLine(this.cursorPosition))
+    } else if (keyBuf[0] === 5) { // ^E
+      this.moveCursorTo(this.getEndOfLine(this.cursorPosition))
+    } else if (keyBuf[0] === 11) { // ^K
+      if (this.cursorPosition[1] === this.getEndOfLine(this.cursorPosition)[1]) {
+        if (this.cursorPosition[0] + 1 < this.sourceLines.length) {
+          this.eraseRange(this.cursorPosition, [this.cursorPosition[0] + 1, 0])
+        }
+      } else {
+        this.eraseRange(this.cursorPosition, this.getEndOfLine(this.cursorPosition))
+      }
+    } else if (telc.isBackspace(keyBuf)) {
+      if (this.escapeJustPressed) {
+        this.eraseWordAtCursor()
+      } else {
+        this.eraseCharacterAtCursor()
+      }
+    } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x7f) {
+      this.eraseWordAtCursor()
+    } else if (keyBuf[0] === 0x17) {
+      this.eraseWordAtCursor()
+    } else if (keyBuf[0] === 0x1b) {
+      if (keyBuf.length === 1) { // Esc
+        this.escapeJustPressed = true
+        clearEscape = false
+      } else {
+        // Some escape code - do nothing.
+      }
+    } else if (keyBuf[0] === 0x0d) { // \r
+      this.insertAtCursor(keyBuf.toString())
+    } else if (keyBuf[0] < 0x20) {
+      // Some other non-printable character - do nothing.
+    } else {
+      this.insertAtCursor(keyBuf.toString())
+    }
+
+    if (clearEscape) {
+      this.escapeJustPressed = false
+    }
+  }
+
+  insertAtCursor(text) {
+    while (this.sourceLines.length <= this.cursorSourceLine) {
+      const sourceLine = {text: ''}
+      this.sourceLines.push(sourceLine)
+      this.uiLines.push({sourceLine, startI: 0, endI: 0})
+    }
+
+    this.moveCursorTo(this.insert(text, this.cursorPosition))
+  }
+
+  eraseCharacterAtCursor() {
+    this.moveCursorTo(this.eraseCharacter(this.cursorPosition))
+  }
+
+  eraseWordAtCursor() {
+    this.moveCursorTo(this.eraseWord(this.cursorPosition))
+  }
+
+  moveCursorTo([sourceLine, sourceCol]) {
+    this.cursorSourceLine = sourceLine
+    this.cursorSourceCol = sourceCol
+    this.idealCursorSourceCol = sourceCol
+  }
+
+  insert(newText, [sourceLine, sourceCol]) {
+    const line = this.sourceLines[sourceLine]
+
+    const newTexts = newText.split(/\r?\n|\r/)
+
+    let resultSourceLine, resultSourceCol
+
+    let newSourceLines = []
+
+    if (newTexts.length === 1) {
+      line.text = line.text.slice(0, sourceCol) + newText + line.text.slice(sourceCol)
+      resultSourceLine = sourceLine
+      resultSourceCol = sourceCol + newText.length
+    } else {
+      const afterText = line.text.slice(sourceCol)
+      line.text = line.text.slice(0, sourceCol) + newTexts[0]
+      newSourceLines = newTexts.slice(1).map(text => ({text}))
+      const lastLine = newSourceLines[newSourceLines.length - 1]
+      resultSourceLine = sourceLine + newTexts.length - 1
+      resultSourceCol = lastLine.text.length
+      lastLine.text += afterText
+      this.sourceLines.splice(sourceLine + 1, 0, ...newSourceLines)
+    }
+
+    this.updateUiForLine(line, newSourceLines.map(line => this.wrapLine(line)).flat())
+
+    return [resultSourceLine, resultSourceCol]
+  }
+
+  eraseCharacter([sourceLine, sourceCol]) {
+    const endPosition = [sourceLine, sourceCol]
+
+    if (sourceCol === 0 && sourceLine === 0) {
+      return endPosition
+    }
+
+    if (sourceCol === 0) {
+      sourceLine--
+      sourceCol = this.sourceLines[sourceLine].text.length
+    } else {
+      sourceCol--
+    }
+
+    return this.eraseRange([sourceLine, sourceCol], endPosition)
+  }
+
+  eraseWord([sourceLine, sourceCol]) {
+    const endPosition = [sourceLine, sourceCol]
+
+    if (sourceCol === 0 && sourceLine === 0) {
+      return endPosition
+    }
+
+    return this.eraseRange(this.getStartOfWord(this.eraseWordBoundary, endPosition), endPosition)
+  }
+
+  getStartOfWord(wordBoundary, [sourceLine, sourceCol]) {
+    while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol - 1])) {
+      sourceCol--
+    }
+
+    while (sourceCol === 0 && sourceLine > 0) {
+      sourceLine--
+      sourceCol = this.sourceLines[sourceLine].text.trimEnd().length
+    }
+
+    const { text } = this.sourceLines[sourceLine]
+    while (!wordBoundary.includes(text[sourceCol - 1]) && sourceCol > 0) {
+      sourceCol--
+    }
+
+    return [sourceLine, sourceCol]
+  }
+
+  getEndOfWord(wordBoundary, [sourceLine, sourceCol]) {
+    while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol])) {
+      sourceCol++
+    }
+
+    let { text } = this.sourceLines[sourceLine]
+    while (sourceCol === text.length && sourceLine < this.sourceLines.length - 1) {
+      sourceLine++
+      sourceCol = text.length - text.trimStart().length
+      text = this.sourceLines[sourceLine].text
+    }
+
+    while (!wordBoundary.includes(text[sourceCol]) && sourceCol < text.length) {
+      sourceCol++
+    }
+
+    return [sourceLine, sourceCol]
+  }
+
+  getStartOfLine([sourceLine,]) {
+    return [sourceLine, 0]
+  }
+
+  getEndOfLine([sourceLine,]) {
+    const { text } = this.sourceLines[sourceLine]
+    return [sourceLine, text.length]
+  }
+
+  eraseRange([startLine, startCol], [endLine, endCol]) {
+    // If the deletion range spreads across more than two lines, it contains
+    // in entirety all lines except the first and start. Remove those lines.
+    if (endLine - startLine > 1) {
+      for (let i = startLine + 1; i < endLine; i++) {
+        this.replaceUiLines(this.sourceLines[i], [])
+      }
+      const numRemoved = endLine - (startLine + 1)
+      this.sourceLines.splice(startLine + 1, numRemoved)
+      // Offset endLine as well.
+      endLine -= numRemoved
+    }
+
+    // If the selection spreads across exactly two lines (which can be the
+    // case naturally, or if the selection originally spread across more than
+    // two lines), the line break between the two will be removed. To effect
+    // this, remove the second line's object, and append its text (past the
+    // end column) to the first line (prior to the start column).
+    if (endLine - startLine === 1) {
+      const firstText = this.sourceLines[startLine].text.slice(0, startCol)
+      const secondText = this.sourceLines[endLine].text.slice(endCol)
+      this.replaceUiLines(this.sourceLines[endLine], [])
+      this.sourceLines.splice(endLine, 1)
+      this.sourceLines[startLine].text = firstText + secondText
+      this.updateUiForLine(this.sourceLines[startLine])
+    }
+
+    // If the selection is contained within a single line, just modify the
+    // text for that line accordingly.
+    if (endLine - startLine === 0) {
+      const line = this.sourceLines[startLine]
+      const firstText = line.text.slice(0, startCol)
+      const secondText = line.text.slice(endCol)
+      line.text = firstText + secondText
+      this.updateUiForLine(line)
+    }
+
+    this.scheduleDrawWithoutPropertyChange()
+
+    return [startLine, startCol]
+  }
+
+  replaceUiLines(line, newUiLines) {
+    const firstIndex = this.uiLines.findIndex(uiLine => uiLine.sourceLine === line)
+    let lastIndex = this.uiLines.slice(firstIndex).findIndex(uiLine => uiLine.sourceLine !== line)
+    if (lastIndex === -1) {
+      lastIndex = this.uiLines.length
+    } else {
+      lastIndex += firstIndex
+    }
+    this.uiLines.splice(firstIndex, lastIndex - firstIndex, ...newUiLines)
+  }
+
+  updateUiForLine(line, additionalLines = []) {
+    const uiLines = this.wrapLine(line)
+    this.replaceUiLines(line, uiLines.concat(additionalLines))
+  }
+
+  wrapLine(sourceLine) {
+    const { text } = sourceLine
+    const lines = []
+    for (let i = 0; i <= text.length; i += this.maxLineWidth) {
+      lines.push({
+        sourceLine,
+        startI: i,
+        endI: Math.min(i + this.maxLineWidth, text.length)
+      })
+    }
+    return lines
+  }
+
+  drawTo(writable) {
+    for (let i = 0; i < this.uiLines.length; i++) {
+      const { sourceLine, startI, endI } = this.uiLines[i]
+      const text = sourceLine.text.slice(startI, endI)
+      writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
+      writable.write(text)
+    }
+  }
+
+  get cursorX() {
+    return this.cursorUiCol + 1
+  }
+
+  get cursorY() {
+    return this.cursorUiLine + 1
+  }
+
+  // The superclass sets cursorX/Y, so we need to define /some/ setter method
+  // for each of these. It doesn't do anything though.
+  set cursorX(v) {}
+  set cursorY(v) {}
+
+  get cursorUiLine() {
+    const sourceLine = this.sourceLines[this.cursorSourceLine]
+    return this.uiLines.findIndex(line =>
+      line.sourceLine === sourceLine &&
+      line.startI <= this.cursorSourceCol &&
+      line.endI >= this.cursorSourceCol)
+  }
+
+  get cursorUiCol() {
+    return this.cursorSourceCol - this.uiLines[this.cursorUiLine].startI
+  }
+
+  get cursorPosition() {
+    return [this.cursorSourceLine, this.cursorSourceCol]
+  }
+
+  get maxLineWidth() {
+    return Math.max(this.contentW, 1)
+  }
+
+  get cursorSourceCol() { return this.getDep('cursorSourceCol') }
+  set cursorSourceCol(v) { return this.setDep('cursorSourceCol', v) }
+  get cursorSourceLine() { return this.getDep('cursorSourceLine') }
+  set cursorSourceLine(v) { return this.setDep('cursorSourceLine', v) }
+}
+
+module.exports = TuiTextEditor