Files
atom/src/cursor.js
2017-11-15 12:52:36 -08:00

758 lines
25 KiB
JavaScript

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: 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 () {
this.setBufferPosition(this.editor.getEofBufferPosition())
}
// 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(),
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(),
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\r ]*$|[^\\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()
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
}
}