diff options
Diffstat (limited to 'index.js')
-rw-r--r-- | index.js | 380 |
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 |