123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- const {
- ui: {
- form: {
- FocusElement,
- ScrollBar
- }
- },
- 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.scrollLine = 0
- this.sourceLines = []
- this.uiLines = []
- this.eraseWordBoundary = ' .*!@#$%^&*()-=+[]{}\\|;:,.<>/?`~'
- this.movementWordBoundary = ' '
- this.tabString = ' '
- this.statusMessageScreenTime = 3000
- this.statusMessages = []
- this.scrollBar = new ScrollBar({
- getLayoutType: () => 'vertical',
- getCurrentScroll: () => this.scrollLine,
- getMaximumScroll: () => this.uiLines.length - this.contentH,
- getTotalItems: () => this.uiLines.length
- })
- this.addChild(this.scrollBar)
- this.statusLineVisible = true
- this.hasBeenEdited = false
- this.rebuildUiLines()
- }
- fixLayout() {
- this.scrollBar.fixLayout()
- this.rebuildUiLines()
- this.scrollCursorIntoView()
- }
- rebuildUiLines() {
- this.uiLines = []
- const { sourceLines } = this
- if (sourceLines.length === 0) {
- sourceLines.push({text: ''})
- }
- for (const line of this.displayedSourceLines) {
- this.uiLines.push(...this.wrapLine(line))
- }
- this.scheduleDrawWithoutPropertyChange()
- }
- keyPressed(keyBuf) {
- let clearEscape = true
- let bubble = false
- if (telc.isDown(keyBuf)) {
- if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
- this.cursorSourceLine++
- this.restoreIdealCursorSourceCol()
- this.scrollCursorIntoView()
- }
- } else if (telc.isUp(keyBuf)) {
- if (this.cursorSourceLine > 0) {
- this.cursorSourceLine--
- this.restoreIdealCursorSourceCol()
- this.scrollCursorIntoView()
- }
- } 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
- this.scrollCursorIntoView()
- } else if (telc.isRight(keyBuf)) {
- this.cursorSourceCol++
- if (this.cursorSourceCol > this.sourceLines[this.cursorSourceLine].text.length) {
- if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
- this.cursorSourceLine++
- this.cursorSourceCol = 0
- } else {
- this.cursorSourceCol--
- }
- }
- this.idealCursorSourceCol = this.cursorSourceCol
- this.scrollCursorIntoView()
- } 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()
- this.scrollCursorIntoView()
- } else if (keyBuf[0] === 1) { // ^A
- this.moveCursorTo(this.getStartOfLine(this.cursorPosition))
- } else if (keyBuf[0] === 5) { // ^E
- this.moveCursorTo(this.getEndOfLine(this.cursorPosition))
- this.idealCursorSourceCol = 'end'
- } else if (keyBuf[0] === 11) { // ^K
- if (this.cursorPosition[1] === this.getEndOfLine(this.cursorPosition)[1]) {
- if (this.cursorPosition[0] < this.displayedSourceLines.length - 1) {
- 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 (telc.isDelete(keyBuf)) {
- if (this.escapeJustPressed) {
- this.eraseWordAtCursorForward()
- } else {
- this.eraseCharacterAtCursorForward()
- }
- } else if (keyBuf[0] === 0x1b) {
- if (keyBuf.length === 1) { // Esc
- this.escapeJustPressed = true
- clearEscape = false
- } else {
- // Some escape code - do nothing.
- bubble = true
- }
- } else if (keyBuf[0] === 0x0d) { // \r
- this.insertAtCursor(keyBuf.toString())
- } else if (keyBuf[0] === 0x09) { // \t
- this.insertAtCursor(this.tabString)
- } else if (keyBuf[0] < 0x20) {
- // Some other non-printable character - do nothing.
- bubble = true
- } else {
- this.insertAtCursor(keyBuf.toString())
- }
- if (clearEscape) {
- this.escapeJustPressed = false
- }
- return bubble
- }
- restoreIdealCursorSourceCol() {
- const end = this.sourceLines[this.cursorSourceLine].text.length
- if (this.idealCursorSourceCol === 'end') {
- this.cursorSourceCol = end
- } else {
- this.cursorSourceCol = Math.min(this.idealCursorSourceCol, end)
- }
- }
- clearSourceAndLoadText(text) {
- this.sourceLines = text.split('\n').map(text => ({text}))
- this.rebuildUiLines()
- this.moveCursorTo([0, 0])
- }
- getSourceText() {
- return this.sourceLines.map(({text}) => text).join('\n')
- }
- insertAtCursor(text) {
- while (this.sourceLines.length <= this.cursorSourceLine) {
- const sourceLine = {text: ''}
- this.sourceLines.push(sourceLine)
- this.uiLines.push({sourceLine, startI: 0, endI: 0})
- }
- this.markEditStatus()
- this.moveCursorTo(this.insert(text, this.cursorPosition))
- }
- eraseCharacterAtCursor() {
- this.moveCursorTo(this.eraseCharacter(this.cursorPosition))
- }
- eraseCharacterAtCursorForward() {
- this.moveCursorTo(this.eraseCharacterForward(this.cursorPosition))
- }
- eraseWordAtCursor() {
- this.moveCursorTo(this.eraseWord(this.cursorPosition))
- }
- eraseWordAtCursorForward() {
- this.moveCursorTo(this.eraseWordForward(this.cursorPosition))
- }
- moveCursorTo([sourceLine, sourceCol]) {
- this.cursorSourceLine = sourceLine
- this.cursorSourceCol = sourceCol
- this.idealCursorSourceCol = sourceCol
- this.scrollCursorIntoView()
- }
- scrollCursorIntoView() {
- this.scrollIntoView([this.cursorUiLine, this.cursorUiCol])
- }
- scrollIntoView([uiLine,]) {
- const bottomEdge = this.scrollLine + this.contentH - 1
- const delta = uiLine - bottomEdge
- if (delta > 0) {
- this.scrollLine += delta
- } else if (uiLine < this.scrollLine) {
- this.scrollLine = uiLine
- }
- }
- 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 prefix = line.text.slice(0, line.text.length - line.text.trimLeft().length)
- const afterText = line.text.slice(sourceCol)
- line.text = line.text.slice(0, sourceCol) + newTexts[0]
- newSourceLines = newTexts.slice(1).map(text => ({text: prefix + 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.markEditStatus()
- this.updateUiForLine(line, newSourceLines.map(line => this.wrapLine(line)).flat())
- return [resultSourceLine, resultSourceCol]
- }
- eraseCharacter(endPosition) {
- return this.eraseRange(this.getPreviousCharacter(endPosition), endPosition)
- }
- eraseCharacterForward(startPosition) {
- return this.eraseRange(startPosition, this.getNextCharacter(startPosition))
- }
- getPreviousCharacter(position) {
- if (this.isSamePosition(position, this.getStartOfSource())) {
- return position
- }
- let [ sourceLine, sourceCol ] = position
- if (sourceCol === 0) {
- sourceLine--
- sourceCol = this.sourceLines[sourceLine].text.length
- } else {
- sourceCol--
- }
- return [sourceLine, sourceCol]
- }
- getNextCharacter(position) {
- if (this.isSamePosition(position, this.getEndOfSource())) {
- return position
- }
- let [ sourceLine, sourceCol ] = position
- if (sourceCol === this.sourceLines[sourceLine].text.length) {
- sourceLine++
- sourceCol = 0
- } else {
- sourceCol++
- }
- return [sourceLine, sourceCol]
- }
- eraseWord(endPosition) {
- return this.eraseRange(this.getStartOfWord(this.eraseWordBoundary, endPosition), endPosition)
- }
- eraseWordForward(startPosition) {
- return this.eraseRange(startPosition, this.getEndOfWord(this.eraseWordBoundary, startPosition))
- }
- getStartOfWord(wordBoundary, position) {
- if (this.isSamePosition(position, this.getStartOfSource())) {
- return position
- }
- let [ sourceLine, sourceCol ] = position
- 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, position) {
- if (this.isSamePosition(position, this.getEndOfSource())) {
- return position
- }
- let [ sourceLine, sourceCol ] = position
- 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]
- }
- getStartOfSource() {
- return [0, 0]
- }
- getEndOfSource() {
- if (this.displayedSourceLines.length) {
- return [this.displayedSourceLines.length - 1, this.sourceLines[this.displayedSourceLines.length - 1].text.length]
- } else {
- return [0, 0]
- }
- }
- isSamePosition([lineA, colA], [lineB, colB]) {
- return lineA === lineB && colA === colB
- }
- 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.markEditStatus()
- 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 = this.scrollLine; i < Math.min(this.uiLines.length, this.scrollLine + this.contentH); i++) {
- const { sourceLine, startI, endI } = this.uiLines[i]
- const text = sourceLine.text.slice(startI, endI)
- writable.write(ansi.moveCursor(this.absTop + i - this.scrollLine, this.absLeft))
- writable.write(text)
- }
- if (this.statusLineVisible) {
- writable.write(ansi.setBackground(ansi.C_BLACK))
- writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
- writable.write(' '.repeat(this.w))
- writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
- this.drawStatusLineContentsTo(writable,
- col => ansi.moveCursor(this.absTop + this.h - 1, this.absLeft + col))
- writable.write(ansi.resetAttributes())
- }
- }
- drawStatusLineContentsTo(writable, setColumn) {
- writable.write(setColumn(1))
- const maxColumn = this.w - 1
- let leftEndColumn = 1
- const latestStatusMessage = this.statusMessages[this.statusMessages.length - 1]
- if (latestStatusMessage) {
- const { text, date } = latestStatusMessage
- if (date > Date.now() - this.statusMessageScreenTime) {
- writable.write(text.slice(0, maxColumn - leftEndColumn))
- leftEndColumn += Math.min(maxColumn - leftEndColumn, text.length)
- }
- }
- const cursorStatus = `${this.cursorSourceLine + 1}/${this.displayedSourceLines.length}:${this.cursorSourceCol}`
- const idealColumn = maxColumn - cursorStatus.length
- const usedColumn = Math.max(leftEndColumn + 1, idealColumn)
- if (usedColumn < maxColumn) {
- writable.write(setColumn(usedColumn))
- writable.write(cursorStatus.slice(usedColumn - idealColumn))
- }
- }
- showStatusMessage(text) {
- const date = Date.now()
- this.statusMessages.push({text, date})
- // Draw now, and again when the time has passed and this message should be
- // hidden.
- this.scheduleDrawWithoutPropertyChange()
- setTimeout(() => this.scheduleDrawWithoutPropertyChange(), this.statusMessageScreenTime)
- }
- markEditStatus() {
- this.hasBeenEdited = true
- }
- clearEditStatus() {
- this.hasBeenEdited = false
- }
- get displayedSourceLines() {
- const last = this.sourceLines[this.sourceLines.length - 1]
- if (this.sourceLines.length > 1 && last.text === '') {
- return this.sourceLines.slice(0, -1)
- } else {
- return this.sourceLines.slice()
- }
- }
- get contentW() {
- return this.w - (this.scrollBar.visible ? 1 : 0)
- }
- get contentH() {
- return this.h - (this.statusLineVisible ? 1 : 0)
- }
- getOptimalHeight() {
- // Basically the inverse to contentH: returns the height with which the
- // editor would display all UI lines at once. Probably wise to call
- // rebuildUiLines() before this!
- return this.uiLines.length + (this.statusLineVisible ? 1 : 0)
- }
- get cursorX() {
- return this.cursorUiCol
- }
- get cursorY() {
- return this.cursorUiLine - this.scrollLine
- }
- // 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) }
- get scrollLine() { return this.getDep('scrollLine') }
- set scrollLine(v) { return this.setDep('scrollLine', v) }
- }
- module.exports = TuiTextEditor
|