const { Point, Range } = require('text-buffer'); const { Emitter } = require('event-kit'); const _ = require('underscore-plus'); const Model = require('./model'); const EmptyLineRegExp = /(\r\n[\t ]*\r\n)|(\n[\t ]*\n)/g; // Extended: The `Cursor` class represents the little blinking line identifying // where text can be inserted. // // Cursors belong to {TextEditor}s and have some metadata attached in the form // of a {DisplayMarker}. module.exports = class Cursor extends Model { // Instantiated by a {TextEditor} constructor(params) { super(params); this.editor = params.editor; this.marker = params.marker; this.emitter = new Emitter(); } destroy() { this.marker.destroy(); } /* Section: Event Subscription */ // Public: Calls your `callback` when the cursor has been moved. // // * `callback` {Function} // * `event` {Object} // * `oldBufferPosition` {Point} // * `oldScreenPosition` {Point} // * `newBufferPosition` {Point} // * `newScreenPosition` {Point} // * `textChanged` {Boolean} // * `cursor` {Cursor} that triggered the event // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangePosition(callback) { return this.emitter.on('did-change-position', callback); } // Public: Calls your `callback` when the cursor is destroyed // // * `callback` {Function} // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy(callback) { return this.emitter.once('did-destroy', callback); } /* Section: Managing Cursor Position */ // Public: Moves a cursor to a given screen position. // // * `screenPosition` {Array} of two numbers: the screen row, and the screen column. // * `options` (optional) {Object} with the following keys: // * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever // the cursor moves to. setScreenPosition(screenPosition, options = {}) { this.changePosition(options, () => { this.marker.setHeadScreenPosition(screenPosition, options); }); } // Public: Returns the screen position of the cursor as a {Point}. getScreenPosition() { return this.marker.getHeadScreenPosition(); } // Public: Moves a cursor to a given buffer position. // // * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. // * `options` (optional) {Object} with the following keys: // * `autoscroll` {Boolean} indicating whether to autoscroll to the new // position. Defaults to `true` if this is the most recently added cursor, // `false` otherwise. setBufferPosition(bufferPosition, options = {}) { this.changePosition(options, () => { this.marker.setHeadBufferPosition(bufferPosition, options); }); } // Public: Returns the current buffer position as an Array. getBufferPosition() { return this.marker.getHeadBufferPosition(); } // Public: Returns the cursor's current screen row. getScreenRow() { return this.getScreenPosition().row; } // Public: Returns the cursor's current screen column. getScreenColumn() { return this.getScreenPosition().column; } // Public: Retrieves the cursor's current buffer row. getBufferRow() { return this.getBufferPosition().row; } // Public: Returns the cursor's current buffer column. getBufferColumn() { return this.getBufferPosition().column; } // Public: Returns the cursor's current buffer row of text excluding its line // ending. getCurrentBufferLine() { return this.editor.lineTextForBufferRow(this.getBufferRow()); } // Public: Returns whether the cursor is at the start of a line. isAtBeginningOfLine() { return this.getBufferPosition().column === 0; } // Public: Returns whether the cursor is on the line return character. isAtEndOfLine() { return this.getBufferPosition().isEqual( this.getCurrentLineBufferRange().end ); } /* Section: Cursor Position Details */ // Public: Returns the underlying {DisplayMarker} for the cursor. // Useful with overlay {Decoration}s. getMarker() { return this.marker; } // Public: Identifies if the cursor is surrounded by whitespace. // // "Surrounded" here means that the character directly before and after the // cursor are both whitespace. // // Returns a {Boolean}. isSurroundedByWhitespace() { const { row, column } = this.getBufferPosition(); const range = [[row, column - 1], [row, column + 1]]; return /^\s+$/.test(this.editor.getTextInBufferRange(range)); } // Public: Returns whether the cursor is currently between a word and non-word // character. The non-word characters are defined by the // `editor.nonWordCharacters` config value. // // This method returns false if the character before or after the cursor is // whitespace. // // Returns a Boolean. isBetweenWordAndNonWord() { if (this.isAtBeginningOfLine() || this.isAtEndOfLine()) return false; const { row, column } = this.getBufferPosition(); const range = [[row, column - 1], [row, column + 1]]; const text = this.editor.getTextInBufferRange(range); if (/\s/.test(text[0]) || /\s/.test(text[1])) return false; const nonWordCharacters = this.getNonWordCharacters(); return ( nonWordCharacters.includes(text[0]) !== nonWordCharacters.includes(text[1]) ); } // Public: Returns whether this cursor is between a word's start and end. // // * `options` (optional) {Object} // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}). // // Returns a {Boolean} isInsideWord(options) { const { row, column } = this.getBufferPosition(); const range = [[row, column], [row, Infinity]]; const text = this.editor.getTextInBufferRange(range); return ( text.search((options && options.wordRegex) || this.wordRegExp()) === 0 ); } // Public: Returns the indentation level of the current line. getIndentLevel() { if (this.editor.getSoftTabs()) { return this.getBufferColumn() / this.editor.getTabLength(); } else { return this.getBufferColumn(); } } // Public: Retrieves the scope descriptor for the cursor's current position. // // Returns a {ScopeDescriptor} getScopeDescriptor() { return this.editor.scopeDescriptorForBufferPosition( this.getBufferPosition() ); } // Public: Retrieves the syntax tree scope descriptor for the cursor's current position. // // Returns a {ScopeDescriptor} getSyntaxTreeScopeDescriptor() { return this.editor.syntaxTreeScopeDescriptorForBufferPosition( this.getBufferPosition() ); } // Public: Returns true if this cursor has no non-whitespace characters before // its current position. hasPrecedingCharactersOnLine() { const bufferPosition = this.getBufferPosition(); const line = this.editor.lineTextForBufferRow(bufferPosition.row); const firstCharacterColumn = line.search(/\S/); if (firstCharacterColumn === -1) { return false; } else { return bufferPosition.column > firstCharacterColumn; } } // Public: Identifies if this cursor is the last in the {TextEditor}. // // "Last" is defined as the most recently added cursor. // // Returns a {Boolean}. isLastCursor() { return this === this.editor.getLastCursor(); } /* Section: Moving the Cursor */ // Public: Moves the cursor up one screen row. // // * `rowCount` (optional) {Number} number of rows to move (default: 1) // * `options` (optional) {Object} with the following keys: // * `moveToEndOfSelection` if true, move to the left of the selection if a // selection exists. moveUp(rowCount = 1, { moveToEndOfSelection } = {}) { let row, column; const range = this.marker.getScreenRange(); if (moveToEndOfSelection && !range.isEmpty()) { ({ row, column } = range.start); } else { ({ row, column } = this.getScreenPosition()); } if (this.goalColumn != null) column = this.goalColumn; this.setScreenPosition( { row: row - rowCount, column }, { skipSoftWrapIndentation: true } ); this.goalColumn = column; } // Public: Moves the cursor down one screen row. // // * `rowCount` (optional) {Number} number of rows to move (default: 1) // * `options` (optional) {Object} with the following keys: // * `moveToEndOfSelection` if true, move to the left of the selection if a // selection exists. moveDown(rowCount = 1, { moveToEndOfSelection } = {}) { let row, column; const range = this.marker.getScreenRange(); if (moveToEndOfSelection && !range.isEmpty()) { ({ row, column } = range.end); } else { ({ row, column } = this.getScreenPosition()); } if (this.goalColumn != null) column = this.goalColumn; this.setScreenPosition( { row: row + rowCount, column }, { skipSoftWrapIndentation: true } ); this.goalColumn = column; } // Public: Moves the cursor left one screen column. // // * `columnCount` (optional) {Number} number of columns to move (default: 1) // * `options` (optional) {Object} with the following keys: // * `moveToEndOfSelection` if true, move to the left of the selection if a // selection exists. moveLeft(columnCount = 1, { moveToEndOfSelection } = {}) { const range = this.marker.getScreenRange(); if (moveToEndOfSelection && !range.isEmpty()) { this.setScreenPosition(range.start); } else { let { row, column } = this.getScreenPosition(); while (columnCount > column && row > 0) { columnCount -= column; column = this.editor.lineLengthForScreenRow(--row); columnCount--; // subtract 1 for the row move } column = column - columnCount; this.setScreenPosition({ row, column }, { clipDirection: 'backward' }); } } // Public: Moves the cursor right one screen column. // // * `columnCount` (optional) {Number} number of columns to move (default: 1) // * `options` (optional) {Object} with the following keys: // * `moveToEndOfSelection` if true, move to the right of the selection if a // selection exists. moveRight(columnCount = 1, { moveToEndOfSelection } = {}) { const range = this.marker.getScreenRange(); if (moveToEndOfSelection && !range.isEmpty()) { this.setScreenPosition(range.end); } else { let { row, column } = this.getScreenPosition(); const maxLines = this.editor.getScreenLineCount(); let rowLength = this.editor.lineLengthForScreenRow(row); let columnsRemainingInLine = rowLength - column; while (columnCount > columnsRemainingInLine && row < maxLines - 1) { columnCount -= columnsRemainingInLine; columnCount--; // subtract 1 for the row move column = 0; rowLength = this.editor.lineLengthForScreenRow(++row); columnsRemainingInLine = rowLength; } column = column + columnCount; this.setScreenPosition({ row, column }, { clipDirection: 'forward' }); } } // Public: Moves the cursor to the top of the buffer. moveToTop() { this.setBufferPosition([0, 0]); } // Public: Moves the cursor to the bottom of the buffer. moveToBottom() { const column = this.goalColumn; this.setBufferPosition(this.editor.getEofBufferPosition()); this.goalColumn = column; } // Public: Moves the cursor to the beginning of the line. moveToBeginningOfScreenLine() { this.setScreenPosition([this.getScreenRow(), 0]); } // Public: Moves the cursor to the beginning of the buffer line. moveToBeginningOfLine() { this.setBufferPosition([this.getBufferRow(), 0]); } // Public: Moves the cursor to the beginning of the first character in the // line. moveToFirstCharacterOfLine() { let targetBufferColumn; const screenRow = this.getScreenRow(); const screenLineStart = this.editor.clipScreenPosition([screenRow, 0], { skipSoftWrapIndentation: true }); const screenLineEnd = [screenRow, Infinity]; const screenLineBufferRange = this.editor.bufferRangeForScreenRange([ screenLineStart, screenLineEnd ]); let firstCharacterColumn = null; this.editor.scanInBufferRange( /\S/, screenLineBufferRange, ({ range, stop }) => { firstCharacterColumn = range.start.column; stop(); } ); if ( firstCharacterColumn != null && firstCharacterColumn !== this.getBufferColumn() ) { targetBufferColumn = firstCharacterColumn; } else { targetBufferColumn = screenLineBufferRange.start.column; } this.setBufferPosition([ screenLineBufferRange.start.row, targetBufferColumn ]); } // Public: Moves the cursor to the end of the line. moveToEndOfScreenLine() { this.setScreenPosition([this.getScreenRow(), Infinity]); } // Public: Moves the cursor to the end of the buffer line. moveToEndOfLine() { this.setBufferPosition([this.getBufferRow(), Infinity]); } // Public: Moves the cursor to the beginning of the word. moveToBeginningOfWord() { this.setBufferPosition(this.getBeginningOfCurrentWordBufferPosition()); } // Public: Moves the cursor to the end of the word. moveToEndOfWord() { const position = this.getEndOfCurrentWordBufferPosition(); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the beginning of the next word. moveToBeginningOfNextWord() { const position = this.getBeginningOfNextWordBufferPosition(); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the previous word boundary. moveToPreviousWordBoundary() { const position = this.getPreviousWordBoundaryBufferPosition(); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the next word boundary. moveToNextWordBoundary() { const position = this.getNextWordBoundaryBufferPosition(); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the previous subword boundary. moveToPreviousSubwordBoundary() { const options = { wordRegex: this.subwordRegExp({ backwards: true }) }; const position = this.getPreviousWordBoundaryBufferPosition(options); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the next subword boundary. moveToNextSubwordBoundary() { const options = { wordRegex: this.subwordRegExp() }; const position = this.getNextWordBoundaryBufferPosition(options); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the beginning of the buffer line, skipping all // whitespace. skipLeadingWhitespace() { const position = this.getBufferPosition(); const scanRange = this.getCurrentLineBufferRange(); let endOfLeadingWhitespace = null; this.editor.scanInBufferRange(/^[ \t]*/, scanRange, ({ range }) => { endOfLeadingWhitespace = range.end; }); if (endOfLeadingWhitespace.isGreaterThan(position)) this.setBufferPosition(endOfLeadingWhitespace); } // Public: Moves the cursor to the beginning of the next paragraph moveToBeginningOfNextParagraph() { const position = this.getBeginningOfNextParagraphBufferPosition(); if (position) this.setBufferPosition(position); } // Public: Moves the cursor to the beginning of the previous paragraph moveToBeginningOfPreviousParagraph() { const position = this.getBeginningOfPreviousParagraphBufferPosition(); if (position) this.setBufferPosition(position); } /* Section: Local Positions and Ranges */ // Public: Returns buffer position of previous word boundary. It might be on // the current word, or the previous word. // // * `options` (optional) {Object} with the following keys: // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}) getPreviousWordBoundaryBufferPosition(options = {}) { const currentBufferPosition = this.getBufferPosition(); const previousNonBlankRow = this.editor.buffer.previousNonBlankRow( currentBufferPosition.row ); const scanRange = Range( Point(previousNonBlankRow || 0, 0), currentBufferPosition ); const ranges = this.editor.buffer.findAllInRangeSync( options.wordRegex || this.wordRegExp(), scanRange ); const range = ranges[ranges.length - 1]; if (range) { if ( range.start.row < currentBufferPosition.row && currentBufferPosition.column > 0 ) { return Point(currentBufferPosition.row, 0); } else if (currentBufferPosition.isGreaterThan(range.end)) { return Point.fromObject(range.end); } else { return Point.fromObject(range.start); } } else { return currentBufferPosition; } } // Public: Returns buffer position of the next word boundary. It might be on // the current word, or the previous word. // // * `options` (optional) {Object} with the following keys: // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}) getNextWordBoundaryBufferPosition(options = {}) { const currentBufferPosition = this.getBufferPosition(); const scanRange = Range( currentBufferPosition, this.editor.getEofBufferPosition() ); const range = this.editor.buffer.findInRangeSync( options.wordRegex || this.wordRegExp(), scanRange ); if (range) { if (range.start.row > currentBufferPosition.row) { return Point(range.start.row, 0); } else if (currentBufferPosition.isLessThan(range.start)) { return Point.fromObject(range.start); } else { return Point.fromObject(range.end); } } else { return currentBufferPosition; } } // Public: Retrieves the buffer position of where the current word starts. // // * `options` (optional) An {Object} with the following keys: // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}). // * `includeNonWordCharacters` A {Boolean} indicating whether to include // non-word characters in the default word regex. // Has no effect if wordRegex is set. // * `allowPrevious` A {Boolean} indicating whether the beginning of the // previous word can be returned. // // Returns a {Range}. getBeginningOfCurrentWordBufferPosition(options = {}) { const allowPrevious = options.allowPrevious !== false; const position = this.getBufferPosition(); const scanRange = allowPrevious ? new Range(new Point(position.row - 1, 0), position) : new Range(new Point(position.row, 0), position); const ranges = this.editor.buffer.findAllInRangeSync( options.wordRegex || this.wordRegExp(options), scanRange ); let result; for (let range of ranges) { if (position.isLessThanOrEqual(range.start)) break; if (allowPrevious || position.isLessThanOrEqual(range.end)) result = Point.fromObject(range.start); } return result || (allowPrevious ? new Point(0, 0) : position); } // Public: Retrieves the buffer position of where the current word ends. // // * `options` (optional) {Object} with the following keys: // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}) // * `includeNonWordCharacters` A Boolean indicating whether to include // non-word characters in the default word regex. Has no effect if // wordRegex is set. // // Returns a {Range}. getEndOfCurrentWordBufferPosition(options = {}) { const allowNext = options.allowNext !== false; const position = this.getBufferPosition(); const scanRange = allowNext ? new Range(position, new Point(position.row + 2, 0)) : new Range(position, new Point(position.row, Infinity)); const ranges = this.editor.buffer.findAllInRangeSync( options.wordRegex || this.wordRegExp(options), scanRange ); for (let range of ranges) { if (position.isLessThan(range.start) && !allowNext) break; if (position.isLessThan(range.end)) return Point.fromObject(range.end); } return allowNext ? this.editor.getEofBufferPosition() : position; } // Public: Retrieves the buffer position of where the next word starts. // // * `options` (optional) {Object} // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}). // // Returns a {Range} getBeginningOfNextWordBufferPosition(options = {}) { const currentBufferPosition = this.getBufferPosition(); const start = this.isInsideWord(options) ? this.getEndOfCurrentWordBufferPosition(options) : currentBufferPosition; const scanRange = [start, this.editor.getEofBufferPosition()]; let beginningOfNextWordPosition; this.editor.scanInBufferRange( options.wordRegex || this.wordRegExp(), scanRange, ({ range, stop }) => { beginningOfNextWordPosition = range.start; stop(); } ); return beginningOfNextWordPosition || currentBufferPosition; } // Public: Returns the buffer Range occupied by the word located under the cursor. // // * `options` (optional) {Object} // * `wordRegex` A {RegExp} indicating what constitutes a "word" // (default: {::wordRegExp}). getCurrentWordBufferRange(options = {}) { const position = this.getBufferPosition(); const ranges = this.editor.buffer.findAllInRangeSync( options.wordRegex || this.wordRegExp(options), new Range(new Point(position.row, 0), new Point(position.row, Infinity)) ); const range = ranges.find( range => range.end.column >= position.column && range.start.column <= position.column ); return range ? Range.fromObject(range) : new Range(position, position); } // Public: Returns the buffer Range for the current line. // // * `options` (optional) {Object} // * `includeNewline` A {Boolean} which controls whether the Range should // include the newline. getCurrentLineBufferRange(options) { return this.editor.bufferRangeForBufferRow(this.getBufferRow(), options); } // Public: Retrieves the range for the current paragraph. // // A paragraph is defined as a block of text surrounded by empty lines or comments. // // Returns a {Range}. getCurrentParagraphBufferRange() { return this.editor.rowRangeForParagraphAtBufferRow(this.getBufferRow()); } // Public: Returns the characters preceding the cursor in the current word. getCurrentWordPrefix() { return this.editor.getTextInBufferRange([ this.getBeginningOfCurrentWordBufferPosition(), this.getBufferPosition() ]); } /* Section: Visibility */ /* Section: Comparing to another cursor */ // Public: Compare this cursor's buffer position to another cursor's buffer position. // // See {Point::compare} for more details. // // * `otherCursor`{Cursor} to compare against compare(otherCursor) { return this.getBufferPosition().compare(otherCursor.getBufferPosition()); } /* Section: Utilities */ // Public: Deselects the current selection. clearSelection(options) { if (this.selection) this.selection.clear(options); } // Public: Get the RegExp used by the cursor to determine what a "word" is. // // * `options` (optional) {Object} with the following keys: // * `includeNonWordCharacters` A {Boolean} indicating whether to include // non-word characters in the regex. (default: true) // // Returns a {RegExp}. wordRegExp(options) { const nonWordCharacters = _.escapeRegExp(this.getNonWordCharacters()); let source = `^[\t ]*$|[^\\s${nonWordCharacters}]+`; if (!options || options.includeNonWordCharacters !== false) { source += `|${`[${nonWordCharacters}]+`}`; } return new RegExp(source, 'g'); } // Public: Get the RegExp used by the cursor to determine what a "subword" is. // // * `options` (optional) {Object} with the following keys: // * `backwards` A {Boolean} indicating whether to look forwards or backwards // for the next subword. (default: false) // // Returns a {RegExp}. subwordRegExp(options = {}) { const nonWordCharacters = this.getNonWordCharacters(); const lowercaseLetters = 'a-z\\u00DF-\\u00F6\\u00F8-\\u00FF'; const uppercaseLetters = 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE'; const snakeCamelSegment = `[${uppercaseLetters}]?[${lowercaseLetters}]+`; const segments = [ '^[\t ]+', '[\t ]+$', `[${uppercaseLetters}]+(?![${lowercaseLetters}])`, '\\d+' ]; if (options.backwards) { segments.push(`${snakeCamelSegment}_*`); segments.push(`[${_.escapeRegExp(nonWordCharacters)}]+\\s*`); } else { segments.push(`_*${snakeCamelSegment}`); segments.push(`\\s*[${_.escapeRegExp(nonWordCharacters)}]+`); } segments.push('_+'); return new RegExp(segments.join('|'), 'g'); } /* Section: Private */ getNonWordCharacters() { return this.editor.getNonWordCharacters(this.getBufferPosition()); } changePosition(options, fn) { this.clearSelection({ autoscroll: false }); fn(); this.goalColumn = null; const autoscroll = options && options.autoscroll != null ? options.autoscroll : this.isLastCursor(); if (autoscroll) this.autoscroll(); } getScreenRange() { const { row, column } = this.getScreenPosition(); return new Range(new Point(row, column), new Point(row, column + 1)); } autoscroll(options = {}) { options.clip = false; this.editor.scrollToScreenRange(this.getScreenRange(), options); } getBeginningOfNextParagraphBufferPosition() { const start = this.getBufferPosition(); const eof = this.editor.getEofBufferPosition(); const scanRange = [start, eof]; const { row, column } = eof; let position = new Point(row, column - 1); this.editor.scanInBufferRange( EmptyLineRegExp, scanRange, ({ range, stop }) => { position = range.start.traverse(Point(1, 0)); if (!position.isEqual(start)) stop(); } ); return position; } getBeginningOfPreviousParagraphBufferPosition() { const start = this.getBufferPosition(); const { row, column } = start; const scanRange = [[row - 1, column], [0, 0]]; let position = new Point(0, 0); this.editor.backwardsScanInBufferRange( EmptyLineRegExp, scanRange, ({ range, stop }) => { position = range.start.traverse(Point(1, 0)); if (!position.isEqual(start)) stop(); } ); return position; } };