index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. const {
  2. ui: {
  3. form: {
  4. FocusElement,
  5. ScrollBar
  6. }
  7. },
  8. util: {
  9. ansi,
  10. telchars: telc
  11. }
  12. } = require('tui-lib')
  13. class TuiTextEditor extends FocusElement {
  14. constructor() {
  15. super()
  16. this.cursorSourceLine = 0
  17. this.cursorSourceCol = 0
  18. this.cursorVisible = true
  19. this.idealCursorSourceCol = 0
  20. this.scrollLine = 0
  21. this.sourceLines = []
  22. this.uiLines = []
  23. this.eraseWordBoundary = ' .*!@#$%^&*()-=+[]{}\\|;:,.<>/?`~'
  24. this.movementWordBoundary = ' '
  25. this.tabString = ' '
  26. this.statusMessageScreenTime = 3000
  27. this.statusMessages = []
  28. this.scrollBar = new ScrollBar({
  29. getLayoutType: () => 'vertical',
  30. getCurrentScroll: () => this.scrollLine,
  31. getMaximumScroll: () => this.uiLines.length - this.contentH,
  32. getTotalItems: () => this.uiLines.length
  33. })
  34. this.addChild(this.scrollBar)
  35. this.statusLineVisible = true
  36. this.hasBeenEdited = false
  37. this.rebuildUiLines()
  38. }
  39. fixLayout() {
  40. this.scrollBar.fixLayout()
  41. this.rebuildUiLines()
  42. this.scrollCursorIntoView()
  43. }
  44. rebuildUiLines() {
  45. this.uiLines = []
  46. const { sourceLines } = this
  47. if (sourceLines.length === 0) {
  48. sourceLines.push({text: ''})
  49. }
  50. for (const line of this.displayedSourceLines) {
  51. this.uiLines.push(...this.wrapLine(line))
  52. }
  53. this.scheduleDrawWithoutPropertyChange()
  54. }
  55. keyPressed(keyBuf) {
  56. let clearEscape = true
  57. let bubble = false
  58. if (telc.isDown(keyBuf)) {
  59. if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
  60. this.cursorSourceLine++
  61. this.restoreIdealCursorSourceCol()
  62. this.scrollCursorIntoView()
  63. }
  64. } else if (telc.isUp(keyBuf)) {
  65. if (this.cursorSourceLine > 0) {
  66. this.cursorSourceLine--
  67. this.restoreIdealCursorSourceCol()
  68. this.scrollCursorIntoView()
  69. }
  70. } else if (telc.isLeft(keyBuf)) {
  71. this.cursorSourceCol--
  72. if (this.cursorSourceCol < 0) {
  73. if (this.cursorSourceLine > 0) {
  74. this.cursorSourceLine--
  75. this.cursorSourceCol = this.sourceLines[this.cursorSourceLine].text.length
  76. } else {
  77. this.cursorSourceCol++
  78. }
  79. }
  80. this.idealCursorSourceCol = this.cursorSourceCol
  81. this.scrollCursorIntoView()
  82. } else if (telc.isRight(keyBuf)) {
  83. this.cursorSourceCol++
  84. if (this.cursorSourceCol > this.sourceLines[this.cursorSourceLine].text.length) {
  85. if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
  86. this.cursorSourceLine++
  87. this.cursorSourceCol = 0
  88. } else {
  89. this.cursorSourceCol--
  90. }
  91. }
  92. this.idealCursorSourceCol = this.cursorSourceCol
  93. this.scrollCursorIntoView()
  94. } else if (telc.isControlLeft(keyBuf)) {
  95. this.moveCursorTo(this.getStartOfWord(this.movementWordBoundary, this.cursorPosition))
  96. } else if (telc.isControlRight(keyBuf)) {
  97. this.moveCursorTo(this.getEndOfWord(this.movementWordBoundary, this.cursorPosition))
  98. } else if (keyBuf[0] === 12) { // ^L
  99. // this.rebuildUiLines()
  100. this.scrollCursorIntoView()
  101. } else if (keyBuf[0] === 1) { // ^A
  102. this.moveCursorTo(this.getStartOfLine(this.cursorPosition))
  103. } else if (keyBuf[0] === 5) { // ^E
  104. this.moveCursorTo(this.getEndOfLine(this.cursorPosition))
  105. this.idealCursorSourceCol = 'end'
  106. } else if (keyBuf[0] === 11) { // ^K
  107. if (this.cursorPosition[1] === this.getEndOfLine(this.cursorPosition)[1]) {
  108. if (this.cursorPosition[0] < this.displayedSourceLines.length - 1) {
  109. this.eraseRange(this.cursorPosition, [this.cursorPosition[0] + 1, 0])
  110. }
  111. } else {
  112. this.eraseRange(this.cursorPosition, this.getEndOfLine(this.cursorPosition))
  113. }
  114. } else if (telc.isBackspace(keyBuf)) {
  115. if (this.escapeJustPressed) {
  116. this.eraseWordAtCursor()
  117. } else {
  118. this.eraseCharacterAtCursor()
  119. }
  120. } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x7f) {
  121. this.eraseWordAtCursor()
  122. } else if (keyBuf[0] === 0x17) {
  123. this.eraseWordAtCursor()
  124. } else if (telc.isDelete(keyBuf)) {
  125. if (this.escapeJustPressed) {
  126. this.eraseWordAtCursorForward()
  127. } else {
  128. this.eraseCharacterAtCursorForward()
  129. }
  130. } else if (keyBuf[0] === 0x1b) {
  131. if (keyBuf.length === 1) { // Esc
  132. this.escapeJustPressed = true
  133. clearEscape = false
  134. } else {
  135. // Some escape code - do nothing.
  136. bubble = true
  137. }
  138. } else if (keyBuf[0] === 0x0d) { // \r
  139. this.insertAtCursor(keyBuf.toString())
  140. } else if (keyBuf[0] === 0x09) { // \t
  141. this.insertAtCursor(this.tabString)
  142. } else if (keyBuf[0] < 0x20) {
  143. // Some other non-printable character - do nothing.
  144. bubble = true
  145. } else {
  146. this.insertAtCursor(keyBuf.toString())
  147. }
  148. if (clearEscape) {
  149. this.escapeJustPressed = false
  150. }
  151. return bubble
  152. }
  153. restoreIdealCursorSourceCol() {
  154. const end = this.sourceLines[this.cursorSourceLine].text.length
  155. if (this.idealCursorSourceCol === 'end') {
  156. this.cursorSourceCol = end
  157. } else {
  158. this.cursorSourceCol = Math.min(this.idealCursorSourceCol, end)
  159. }
  160. }
  161. clearSourceAndLoadText(text) {
  162. this.sourceLines = text.split('\n').map(text => ({text}))
  163. this.rebuildUiLines()
  164. this.moveCursorTo([0, 0])
  165. }
  166. getSourceText() {
  167. return this.sourceLines.map(({text}) => text).join('\n')
  168. }
  169. insertAtCursor(text) {
  170. while (this.sourceLines.length <= this.cursorSourceLine) {
  171. const sourceLine = {text: ''}
  172. this.sourceLines.push(sourceLine)
  173. this.uiLines.push({sourceLine, startI: 0, endI: 0})
  174. }
  175. this.markEditStatus()
  176. this.moveCursorTo(this.insert(text, this.cursorPosition))
  177. }
  178. eraseCharacterAtCursor() {
  179. this.moveCursorTo(this.eraseCharacter(this.cursorPosition))
  180. }
  181. eraseCharacterAtCursorForward() {
  182. this.moveCursorTo(this.eraseCharacterForward(this.cursorPosition))
  183. }
  184. eraseWordAtCursor() {
  185. this.moveCursorTo(this.eraseWord(this.cursorPosition))
  186. }
  187. eraseWordAtCursorForward() {
  188. this.moveCursorTo(this.eraseWordForward(this.cursorPosition))
  189. }
  190. moveCursorTo([sourceLine, sourceCol]) {
  191. this.cursorSourceLine = sourceLine
  192. this.cursorSourceCol = sourceCol
  193. this.idealCursorSourceCol = sourceCol
  194. this.scrollCursorIntoView()
  195. }
  196. scrollCursorIntoView() {
  197. this.scrollIntoView([this.cursorUiLine, this.cursorUiCol])
  198. }
  199. scrollIntoView([uiLine,]) {
  200. const bottomEdge = this.scrollLine + this.contentH - 1
  201. const delta = uiLine - bottomEdge
  202. if (delta > 0) {
  203. this.scrollLine += delta
  204. } else if (uiLine < this.scrollLine) {
  205. this.scrollLine = uiLine
  206. }
  207. }
  208. insert(newText, [sourceLine, sourceCol]) {
  209. const line = this.sourceLines[sourceLine]
  210. const newTexts = newText.split(/\r?\n|\r/)
  211. let resultSourceLine, resultSourceCol
  212. let newSourceLines = []
  213. if (newTexts.length === 1) {
  214. line.text = line.text.slice(0, sourceCol) + newText + line.text.slice(sourceCol)
  215. resultSourceLine = sourceLine
  216. resultSourceCol = sourceCol + newText.length
  217. } else {
  218. const prefix = line.text.slice(0, line.text.length - line.text.trimLeft().length)
  219. const afterText = line.text.slice(sourceCol)
  220. line.text = line.text.slice(0, sourceCol) + newTexts[0]
  221. newSourceLines = newTexts.slice(1).map(text => ({text: prefix + text}))
  222. const lastLine = newSourceLines[newSourceLines.length - 1]
  223. resultSourceLine = sourceLine + newTexts.length - 1
  224. resultSourceCol = lastLine.text.length
  225. lastLine.text += afterText
  226. this.sourceLines.splice(sourceLine + 1, 0, ...newSourceLines)
  227. }
  228. this.markEditStatus()
  229. this.updateUiForLine(line, newSourceLines.map(line => this.wrapLine(line)).flat())
  230. return [resultSourceLine, resultSourceCol]
  231. }
  232. eraseCharacter(endPosition) {
  233. return this.eraseRange(this.getPreviousCharacter(endPosition), endPosition)
  234. }
  235. eraseCharacterForward(startPosition) {
  236. return this.eraseRange(startPosition, this.getNextCharacter(startPosition))
  237. }
  238. getPreviousCharacter(position) {
  239. if (this.isSamePosition(position, this.getStartOfSource())) {
  240. return position
  241. }
  242. let [ sourceLine, sourceCol ] = position
  243. if (sourceCol === 0) {
  244. sourceLine--
  245. sourceCol = this.sourceLines[sourceLine].text.length
  246. } else {
  247. sourceCol--
  248. }
  249. return [sourceLine, sourceCol]
  250. }
  251. getNextCharacter(position) {
  252. if (this.isSamePosition(position, this.getEndOfSource())) {
  253. return position
  254. }
  255. let [ sourceLine, sourceCol ] = position
  256. if (sourceCol === this.sourceLines[sourceLine].text.length) {
  257. sourceLine++
  258. sourceCol = 0
  259. } else {
  260. sourceCol++
  261. }
  262. return [sourceLine, sourceCol]
  263. }
  264. eraseWord(endPosition) {
  265. return this.eraseRange(this.getStartOfWord(this.eraseWordBoundary, endPosition), endPosition)
  266. }
  267. eraseWordForward(startPosition) {
  268. return this.eraseRange(startPosition, this.getEndOfWord(this.eraseWordBoundary, startPosition))
  269. }
  270. getStartOfWord(wordBoundary, position) {
  271. if (this.isSamePosition(position, this.getStartOfSource())) {
  272. return position
  273. }
  274. let [ sourceLine, sourceCol ] = position
  275. while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol - 1])) {
  276. sourceCol--
  277. }
  278. while (sourceCol === 0 && sourceLine > 0) {
  279. sourceLine--
  280. sourceCol = this.sourceLines[sourceLine].text.trimEnd().length
  281. }
  282. const { text } = this.sourceLines[sourceLine]
  283. while (!wordBoundary.includes(text[sourceCol - 1]) && sourceCol > 0) {
  284. sourceCol--
  285. }
  286. return [sourceLine, sourceCol]
  287. }
  288. getEndOfWord(wordBoundary, position) {
  289. if (this.isSamePosition(position, this.getEndOfSource())) {
  290. return position
  291. }
  292. let [ sourceLine, sourceCol ] = position
  293. while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol])) {
  294. sourceCol++
  295. }
  296. let { text } = this.sourceLines[sourceLine]
  297. while (sourceCol === text.length && sourceLine < this.sourceLines.length - 1) {
  298. sourceLine++
  299. sourceCol = text.length - text.trimStart().length
  300. text = this.sourceLines[sourceLine].text
  301. }
  302. while (!wordBoundary.includes(text[sourceCol]) && sourceCol < text.length) {
  303. sourceCol++
  304. }
  305. return [sourceLine, sourceCol]
  306. }
  307. getStartOfLine([sourceLine,]) {
  308. return [sourceLine, 0]
  309. }
  310. getEndOfLine([sourceLine,]) {
  311. const { text } = this.sourceLines[sourceLine]
  312. return [sourceLine, text.length]
  313. }
  314. getStartOfSource() {
  315. return [0, 0]
  316. }
  317. getEndOfSource() {
  318. if (this.displayedSourceLines.length) {
  319. return [this.displayedSourceLines.length - 1, this.sourceLines[this.displayedSourceLines.length - 1].text.length]
  320. } else {
  321. return [0, 0]
  322. }
  323. }
  324. isSamePosition([lineA, colA], [lineB, colB]) {
  325. return lineA === lineB && colA === colB
  326. }
  327. eraseRange([startLine, startCol], [endLine, endCol]) {
  328. // If the deletion range spreads across more than two lines, it contains
  329. // in entirety all lines except the first and start. Remove those lines.
  330. if (endLine - startLine > 1) {
  331. for (let i = startLine + 1; i < endLine; i++) {
  332. this.replaceUiLines(this.sourceLines[i], [])
  333. }
  334. const numRemoved = endLine - (startLine + 1)
  335. this.sourceLines.splice(startLine + 1, numRemoved)
  336. // Offset endLine as well.
  337. endLine -= numRemoved
  338. }
  339. // If the selection spreads across exactly two lines (which can be the
  340. // case naturally, or if the selection originally spread across more than
  341. // two lines), the line break between the two will be removed. To effect
  342. // this, remove the second line's object, and append its text (past the
  343. // end column) to the first line (prior to the start column).
  344. if (endLine - startLine === 1) {
  345. const firstText = this.sourceLines[startLine].text.slice(0, startCol)
  346. const secondText = this.sourceLines[endLine].text.slice(endCol)
  347. this.replaceUiLines(this.sourceLines[endLine], [])
  348. this.sourceLines.splice(endLine, 1)
  349. this.sourceLines[startLine].text = firstText + secondText
  350. this.updateUiForLine(this.sourceLines[startLine])
  351. }
  352. // If the selection is contained within a single line, just modify the
  353. // text for that line accordingly.
  354. if (endLine - startLine === 0) {
  355. const line = this.sourceLines[startLine]
  356. const firstText = line.text.slice(0, startCol)
  357. const secondText = line.text.slice(endCol)
  358. line.text = firstText + secondText
  359. this.updateUiForLine(line)
  360. }
  361. this.markEditStatus()
  362. this.scheduleDrawWithoutPropertyChange()
  363. return [startLine, startCol]
  364. }
  365. replaceUiLines(line, newUiLines) {
  366. const firstIndex = this.uiLines.findIndex(uiLine => uiLine.sourceLine === line)
  367. let lastIndex = this.uiLines.slice(firstIndex).findIndex(uiLine => uiLine.sourceLine !== line)
  368. if (lastIndex === -1) {
  369. lastIndex = this.uiLines.length
  370. } else {
  371. lastIndex += firstIndex
  372. }
  373. this.uiLines.splice(firstIndex, lastIndex - firstIndex, ...newUiLines)
  374. }
  375. updateUiForLine(line, additionalLines = []) {
  376. const uiLines = this.wrapLine(line)
  377. this.replaceUiLines(line, uiLines.concat(additionalLines))
  378. }
  379. wrapLine(sourceLine) {
  380. const { text } = sourceLine
  381. const lines = []
  382. for (let i = 0; i <= text.length; i += this.maxLineWidth) {
  383. lines.push({
  384. sourceLine,
  385. startI: i,
  386. endI: Math.min(i + this.maxLineWidth, text.length)
  387. })
  388. }
  389. return lines
  390. }
  391. drawTo(writable) {
  392. for (let i = this.scrollLine; i < Math.min(this.uiLines.length, this.scrollLine + this.contentH); i++) {
  393. const { sourceLine, startI, endI } = this.uiLines[i]
  394. const text = sourceLine.text.slice(startI, endI)
  395. writable.write(ansi.moveCursor(this.absTop + i - this.scrollLine, this.absLeft))
  396. writable.write(text)
  397. }
  398. if (this.statusLineVisible) {
  399. writable.write(ansi.setBackground(ansi.C_BLACK))
  400. writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
  401. writable.write(' '.repeat(this.w))
  402. writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
  403. this.drawStatusLineContentsTo(writable,
  404. col => ansi.moveCursor(this.absTop + this.h - 1, this.absLeft + col))
  405. writable.write(ansi.resetAttributes())
  406. }
  407. }
  408. drawStatusLineContentsTo(writable, setColumn) {
  409. writable.write(setColumn(1))
  410. const maxColumn = this.w - 1
  411. let leftEndColumn = 1
  412. const latestStatusMessage = this.statusMessages[this.statusMessages.length - 1]
  413. if (latestStatusMessage) {
  414. const { text, date } = latestStatusMessage
  415. if (date > Date.now() - this.statusMessageScreenTime) {
  416. writable.write(text.slice(0, maxColumn - leftEndColumn))
  417. leftEndColumn += Math.min(maxColumn - leftEndColumn, text.length)
  418. }
  419. }
  420. const cursorStatus = `${this.cursorSourceLine + 1}/${this.displayedSourceLines.length}:${this.cursorSourceCol}`
  421. const idealColumn = maxColumn - cursorStatus.length
  422. const usedColumn = Math.max(leftEndColumn + 1, idealColumn)
  423. if (usedColumn < maxColumn) {
  424. writable.write(setColumn(usedColumn))
  425. writable.write(cursorStatus.slice(usedColumn - idealColumn))
  426. }
  427. }
  428. showStatusMessage(text) {
  429. const date = Date.now()
  430. this.statusMessages.push({text, date})
  431. // Draw now, and again when the time has passed and this message should be
  432. // hidden.
  433. this.scheduleDrawWithoutPropertyChange()
  434. setTimeout(() => this.scheduleDrawWithoutPropertyChange(), this.statusMessageScreenTime)
  435. }
  436. markEditStatus() {
  437. this.hasBeenEdited = true
  438. }
  439. clearEditStatus() {
  440. this.hasBeenEdited = false
  441. }
  442. get displayedSourceLines() {
  443. const last = this.sourceLines[this.sourceLines.length - 1]
  444. if (this.sourceLines.length > 1 && last.text === '') {
  445. return this.sourceLines.slice(0, -1)
  446. } else {
  447. return this.sourceLines.slice()
  448. }
  449. }
  450. get contentW() {
  451. return this.w - (this.scrollBar.visible ? 1 : 0)
  452. }
  453. get contentH() {
  454. return this.h - (this.statusLineVisible ? 1 : 0)
  455. }
  456. getOptimalHeight() {
  457. // Basically the inverse to contentH: returns the height with which the
  458. // editor would display all UI lines at once. Probably wise to call
  459. // rebuildUiLines() before this!
  460. return this.uiLines.length + (this.statusLineVisible ? 1 : 0)
  461. }
  462. get cursorX() {
  463. return this.cursorUiCol
  464. }
  465. get cursorY() {
  466. return this.cursorUiLine - this.scrollLine
  467. }
  468. // The superclass sets cursorX/Y, so we need to define /some/ setter method
  469. // for each of these. It doesn't do anything though.
  470. set cursorX(v) {}
  471. set cursorY(v) {}
  472. get cursorUiLine() {
  473. const sourceLine = this.sourceLines[this.cursorSourceLine]
  474. return this.uiLines.findIndex(line =>
  475. line.sourceLine === sourceLine &&
  476. line.startI <= this.cursorSourceCol &&
  477. line.endI >= this.cursorSourceCol)
  478. }
  479. get cursorUiCol() {
  480. return this.cursorSourceCol - this.uiLines[this.cursorUiLine].startI
  481. }
  482. get cursorPosition() {
  483. return [this.cursorSourceLine, this.cursorSourceCol]
  484. }
  485. get maxLineWidth() {
  486. return Math.max(this.contentW, 1)
  487. }
  488. get cursorSourceCol() { return this.getDep('cursorSourceCol') }
  489. set cursorSourceCol(v) { return this.setDep('cursorSourceCol', v) }
  490. get cursorSourceLine() { return this.getDep('cursorSourceLine') }
  491. set cursorSourceLine(v) { return this.setDep('cursorSourceLine', v) }
  492. get scrollLine() { return this.getDep('scrollLine') }
  493. set scrollLine(v) { return this.setDep('scrollLine', v) }
  494. }
  495. module.exports = TuiTextEditor