From 4ce351d0f34fc36cfd4cfc3b304e6af026257ed6 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 2 Nov 2017 09:22:58 -0700 Subject: [PATCH 1/3] Convert Selection to JS --- src/selection.coffee | 840 ------------------------------------- src/selection.js | 975 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 975 insertions(+), 840 deletions(-) delete mode 100644 src/selection.coffee create mode 100644 src/selection.js diff --git a/src/selection.coffee b/src/selection.coffee deleted file mode 100644 index e55f17e88..000000000 --- a/src/selection.coffee +++ /dev/null @@ -1,840 +0,0 @@ -{Point, Range} = require 'text-buffer' -{pick} = require 'underscore-plus' -{Emitter} = require 'event-kit' -Model = require './model' - -NonWhitespaceRegExp = /\S/ - -# Extended: Represents a selection in the {TextEditor}. -module.exports = -class Selection extends Model - cursor: null - marker: null - editor: null - initialScreenRange: null - wordwise: false - - constructor: ({@cursor, @marker, @editor, id}) -> - @emitter = new Emitter - - @assignId(id) - @cursor.selection = this - @decoration = @editor.decorateMarker(@marker, type: 'highlight', class: 'selection') - - @marker.onDidChange (e) => @markerDidChange(e) - @marker.onDidDestroy => @markerDidDestroy() - - destroy: -> - @marker.destroy() - - isLastSelection: -> - this is @editor.getLastSelection() - - ### - Section: Event Subscription - ### - - # Extended: Calls your `callback` when the selection was moved. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferRange` {Range} - # * `oldScreenRange` {Range} - # * `newBufferRange` {Range} - # * `newScreenRange` {Range} - # * `selection` {Selection} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeRange: (callback) -> - @emitter.on 'did-change-range', callback - - # Extended: Calls your `callback` when the selection was destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - ### - Section: Managing the selection range - ### - - # Public: Returns the screen {Range} for the selection. - getScreenRange: -> - @marker.getScreenRange() - - # Public: Modifies the screen range for the selection. - # - # * `screenRange` The new {Range} to use. - # * `options` (optional) {Object} options matching those found in {::setBufferRange}. - setScreenRange: (screenRange, options) -> - @setBufferRange(@editor.bufferRangeForScreenRange(screenRange), options) - - # Public: Returns the buffer {Range} for the selection. - getBufferRange: -> - @marker.getBufferRange() - - # Public: Modifies the buffer {Range} for the selection. - # - # * `bufferRange` The new {Range} to select. - # * `options` (optional) {Object} with the keys: - # * `preserveFolds` if `true`, the fold settings are preserved after the - # selection moves. - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # range. Defaults to `true` if this is the most recently added selection, - # `false` otherwise. - setBufferRange: (bufferRange, options={}) -> - bufferRange = Range.fromObject(bufferRange) - options.reversed ?= @isReversed() - @editor.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) unless options.preserveFolds - @modifySelection => - needsFlash = options.flash - delete options.flash if options.flash? - @marker.setBufferRange(bufferRange, options) - @autoscroll() if options?.autoscroll ? @isLastSelection() - @decoration.flash('flash', @editor.selectionFlashDuration) if needsFlash - - # Public: Returns the starting and ending buffer rows the selection is - # highlighting. - # - # Returns an {Array} of two {Number}s: the starting row, and the ending row. - getBufferRowRange: -> - range = @getBufferRange() - start = range.start.row - end = range.end.row - end = Math.max(start, end - 1) if range.end.column is 0 - [start, end] - - getTailScreenPosition: -> - @marker.getTailScreenPosition() - - getTailBufferPosition: -> - @marker.getTailBufferPosition() - - getHeadScreenPosition: -> - @marker.getHeadScreenPosition() - - getHeadBufferPosition: -> - @marker.getHeadBufferPosition() - - ### - Section: Info about the selection - ### - - # Public: Determines if the selection contains anything. - isEmpty: -> - @getBufferRange().isEmpty() - - # Public: Determines if the ending position of a marker is greater than the - # starting position. - # - # This can happen when, for example, you highlight text "up" in a {TextBuffer}. - isReversed: -> - @marker.isReversed() - - # Public: Returns whether the selection is a single line or not. - isSingleScreenLine: -> - @getScreenRange().isSingleLine() - - # Public: Returns the text in the selection. - getText: -> - @editor.buffer.getTextInRange(@getBufferRange()) - - # Public: Identifies if a selection intersects with a given buffer range. - # - # * `bufferRange` A {Range} to check against. - # - # Returns a {Boolean} - intersectsBufferRange: (bufferRange) -> - @getBufferRange().intersectsWith(bufferRange) - - intersectsScreenRowRange: (startRow, endRow) -> - @getScreenRange().intersectsRowRange(startRow, endRow) - - intersectsScreenRow: (screenRow) -> - @getScreenRange().intersectsRow(screenRow) - - # Public: Identifies if a selection intersects with another selection. - # - # * `otherSelection` A {Selection} to check against. - # - # Returns a {Boolean} - intersectsWith: (otherSelection, exclusive) -> - @getBufferRange().intersectsWith(otherSelection.getBufferRange(), exclusive) - - ### - Section: Modifying the selected range - ### - - # Public: Clears the selection, moving the marker to the head. - # - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # range. Defaults to `true` if this is the most recently added selection, - # `false` otherwise. - clear: (options) -> - @goalScreenRange = null - @marker.clearTail() unless @retainSelection - @autoscroll() if options?.autoscroll ? @isLastSelection() - @finalize() - - # Public: Selects the text from the current cursor position to a given screen - # position. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToScreenPosition: (position, options) -> - position = Point.fromObject(position) - - @modifySelection => - if @initialScreenRange - if position.isLessThan(@initialScreenRange.start) - @marker.setScreenRange([position, @initialScreenRange.end], reversed: true) - else - @marker.setScreenRange([@initialScreenRange.start, position], reversed: false) - else - @cursor.setScreenPosition(position, options) - - if @linewise - @expandOverLine(options) - else if @wordwise - @expandOverWord(options) - - # Public: Selects the text from the current cursor position to a given buffer - # position. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToBufferPosition: (position) -> - @modifySelection => @cursor.setBufferPosition(position) - - # Public: Selects the text one position right of the cursor. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - selectRight: (columnCount) -> - @modifySelection => @cursor.moveRight(columnCount) - - # Public: Selects the text one position left of the cursor. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - selectLeft: (columnCount) -> - @modifySelection => @cursor.moveLeft(columnCount) - - # Public: Selects all the text one position above the cursor. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - selectUp: (rowCount) -> - @modifySelection => @cursor.moveUp(rowCount) - - # Public: Selects all the text one position below the cursor. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - selectDown: (rowCount) -> - @modifySelection => @cursor.moveDown(rowCount) - - # Public: Selects all the text from the current cursor position to the top of - # the buffer. - selectToTop: -> - @modifySelection => @cursor.moveToTop() - - # Public: Selects all the text from the current cursor position to the bottom - # of the buffer. - selectToBottom: -> - @modifySelection => @cursor.moveToBottom() - - # Public: Selects all the text in the buffer. - selectAll: -> - @setBufferRange(@editor.buffer.getRange(), autoscroll: false) - - # Public: Selects all the text from the current cursor position to the - # beginning of the line. - selectToBeginningOfLine: -> - @modifySelection => @cursor.moveToBeginningOfLine() - - # Public: Selects all the text from the current cursor position to the first - # character of the line. - selectToFirstCharacterOfLine: -> - @modifySelection => @cursor.moveToFirstCharacterOfLine() - - # Public: Selects all the text from the current cursor position to the end of - # the screen line. - selectToEndOfLine: -> - @modifySelection => @cursor.moveToEndOfScreenLine() - - # Public: Selects all the text from the current cursor position to the end of - # the buffer line. - selectToEndOfBufferLine: -> - @modifySelection => @cursor.moveToEndOfLine() - - # Public: Selects all the text from the current cursor position to the - # beginning of the word. - selectToBeginningOfWord: -> - @modifySelection => @cursor.moveToBeginningOfWord() - - # Public: Selects all the text from the current cursor position to the end of - # the word. - selectToEndOfWord: -> - @modifySelection => @cursor.moveToEndOfWord() - - # Public: Selects all the text from the current cursor position to the - # beginning of the next word. - selectToBeginningOfNextWord: -> - @modifySelection => @cursor.moveToBeginningOfNextWord() - - # Public: Selects text to the previous word boundary. - selectToPreviousWordBoundary: -> - @modifySelection => @cursor.moveToPreviousWordBoundary() - - # Public: Selects text to the next word boundary. - selectToNextWordBoundary: -> - @modifySelection => @cursor.moveToNextWordBoundary() - - # Public: Selects text to the previous subword boundary. - selectToPreviousSubwordBoundary: -> - @modifySelection => @cursor.moveToPreviousSubwordBoundary() - - # Public: Selects text to the next subword boundary. - selectToNextSubwordBoundary: -> - @modifySelection => @cursor.moveToNextSubwordBoundary() - - # Public: Selects all the text from the current cursor position to the - # beginning of the next paragraph. - selectToBeginningOfNextParagraph: -> - @modifySelection => @cursor.moveToBeginningOfNextParagraph() - - # Public: Selects all the text from the current cursor position to the - # beginning of the previous paragraph. - selectToBeginningOfPreviousParagraph: -> - @modifySelection => @cursor.moveToBeginningOfPreviousParagraph() - - # Public: Modifies the selection to encompass the current word. - # - # Returns a {Range}. - selectWord: (options={}) -> - options.wordRegex = /[\t ]*/ if @cursor.isSurroundedByWhitespace() - if @cursor.isBetweenWordAndNonWord() - options.includeNonWordCharacters = false - - @setBufferRange(@cursor.getCurrentWordBufferRange(options), options) - @wordwise = true - @initialScreenRange = @getScreenRange() - - # Public: Expands the newest selection to include the entire word on which - # the cursors rests. - expandOverWord: (options) -> - @setBufferRange(@getBufferRange().union(@cursor.getCurrentWordBufferRange()), autoscroll: false) - @cursor.autoscroll() if options?.autoscroll ? true - - # Public: Selects an entire line in the buffer. - # - # * `row` The line {Number} to select (default: the row of the cursor). - selectLine: (row, options) -> - if row? - @setBufferRange(@editor.bufferRangeForBufferRow(row, includeNewline: true), options) - else - startRange = @editor.bufferRangeForBufferRow(@marker.getStartBufferPosition().row) - endRange = @editor.bufferRangeForBufferRow(@marker.getEndBufferPosition().row, includeNewline: true) - @setBufferRange(startRange.union(endRange), options) - - @linewise = true - @wordwise = false - @initialScreenRange = @getScreenRange() - - # Public: Expands the newest selection to include the entire line on which - # the cursor currently rests. - # - # It also includes the newline character. - expandOverLine: (options) -> - range = @getBufferRange().union(@cursor.getCurrentLineBufferRange(includeNewline: true)) - @setBufferRange(range, autoscroll: false) - @cursor.autoscroll() if options?.autoscroll ? true - - ### - Section: Modifying the selected text - ### - - # Public: Replaces text at the current selection. - # - # * `text` A {String} representing the text to add - # * `options` (optional) {Object} with keys: - # * `select` If `true`, selects the newly added text. - # * `autoIndent` If `true`, indents all inserted text appropriately. - # * `autoIndentNewline` If `true`, indent newline appropriately. - # * `autoDecreaseIndent` If `true`, decreases indent level appropriately - # (for example, when a closing bracket is inserted). - # * `preserveTrailingLineIndentation` By default, when pasting multiple - # lines, Atom attempts to preserve the relative indent level between the - # first line and trailing lines, even if the indent level of the first - # line has changed from the copied text. If this option is `true`, this - # behavior is suppressed. - # level between the first lines and the trailing lines. - # * `normalizeLineEndings` (optional) {Boolean} (default: true) - # * `undo` If `skip`, skips the undo stack for this operation. - insertText: (text, options={}) -> - oldBufferRange = @getBufferRange() - wasReversed = @isReversed() - @clear(options) - - autoIndentFirstLine = false - precedingText = @editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start]) - remainingLines = text.split('\n') - firstInsertedLine = remainingLines.shift() - - if options.indentBasis? and not options.preserveTrailingLineIndentation - indentAdjustment = @editor.indentLevelForLine(precedingText) - options.indentBasis - @adjustIndent(remainingLines, indentAdjustment) - - textIsAutoIndentable = text is '\n' or text is '\r\n' or NonWhitespaceRegExp.test(text) - if options.autoIndent and textIsAutoIndentable and not NonWhitespaceRegExp.test(precedingText) and remainingLines.length > 0 - autoIndentFirstLine = true - firstLine = precedingText + firstInsertedLine - desiredIndentLevel = @editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) - indentAdjustment = desiredIndentLevel - @editor.indentLevelForLine(firstLine) - @adjustIndent(remainingLines, indentAdjustment) - - text = firstInsertedLine - text += '\n' + remainingLines.join('\n') if remainingLines.length > 0 - - newBufferRange = @editor.buffer.setTextInRange(oldBufferRange, text, pick(options, 'undo', 'normalizeLineEndings')) - - if options.select - @setBufferRange(newBufferRange, reversed: wasReversed) - else - @cursor.setBufferPosition(newBufferRange.end) if wasReversed - - if autoIndentFirstLine - @editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel) - - if options.autoIndentNewline and text is '\n' - @editor.autoIndentBufferRow(newBufferRange.end.row, preserveLeadingWhitespace: true, skipBlankLines: false) - else if options.autoDecreaseIndent and NonWhitespaceRegExp.test(text) - @editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row) - - @autoscroll() if options.autoscroll ? @isLastSelection() - - newBufferRange - - # Public: Removes the first character before the selection if the selection - # is empty otherwise it deletes the selection. - backspace: -> - @selectLeft() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or, if nothing is selected, then all - # characters from the start of the selection back to the previous word - # boundary. - deleteToPreviousWordBoundary: -> - @selectToPreviousWordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or, if nothing is selected, then all - # characters from the start of the selection up to the next word - # boundary. - deleteToNextWordBoundary: -> - @selectToNextWordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes from the start of the selection to the beginning of the - # current word if the selection is empty otherwise it deletes the selection. - deleteToBeginningOfWord: -> - @selectToBeginningOfWord() if @isEmpty() - @deleteSelectedText() - - # Public: Removes from the beginning of the line which the selection begins on - # all the way through to the end of the selection. - deleteToBeginningOfLine: -> - if @isEmpty() and @cursor.isAtBeginningOfLine() - @selectLeft() - else - @selectToBeginningOfLine() - @deleteSelectedText() - - # Public: Removes the selection or the next character after the start of the - # selection if the selection is empty. - delete: -> - @selectRight() if @isEmpty() - @deleteSelectedText() - - # Public: If the selection is empty, removes all text from the cursor to the - # end of the line. If the cursor is already at the end of the line, it - # removes the following newline. If the selection isn't empty, only deletes - # the contents of the selection. - deleteToEndOfLine: -> - return @delete() if @isEmpty() and @cursor.isAtEndOfLine() - @selectToEndOfLine() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or all characters from the start of the - # selection to the end of the current word if nothing is selected. - deleteToEndOfWord: -> - @selectToEndOfWord() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or all characters from the start of the - # selection to the end of the current word if nothing is selected. - deleteToBeginningOfSubword: -> - @selectToPreviousSubwordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or all characters from the start of the - # selection to the end of the current word if nothing is selected. - deleteToEndOfSubword: -> - @selectToNextSubwordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes only the selected text. - deleteSelectedText: -> - bufferRange = @getBufferRange() - @editor.buffer.delete(bufferRange) unless bufferRange.isEmpty() - @cursor?.setBufferPosition(bufferRange.start) - - # Public: Removes the line at the beginning of the selection if the selection - # is empty unless the selection spans multiple lines in which case all lines - # are removed. - deleteLine: -> - if @isEmpty() - start = @cursor.getScreenRow() - range = @editor.bufferRowsForScreenRows(start, start + 1) - if range[1] > range[0] - @editor.buffer.deleteRows(range[0], range[1] - 1) - else - @editor.buffer.deleteRow(range[0]) - else - range = @getBufferRange() - start = range.start.row - end = range.end.row - if end isnt @editor.buffer.getLastRow() and range.end.column is 0 - end-- - @editor.buffer.deleteRows(start, end) - - # Public: Joins the current line with the one below it. Lines will - # be separated by a single space. - # - # If there selection spans more than one line, all the lines are joined together. - joinLines: -> - selectedRange = @getBufferRange() - if selectedRange.isEmpty() - return if selectedRange.start.row is @editor.buffer.getLastRow() - else - joinMarker = @editor.markBufferRange(selectedRange, invalidate: 'never') - - rowCount = Math.max(1, selectedRange.getRowCount() - 1) - for [0...rowCount] - @cursor.setBufferPosition([selectedRange.start.row]) - @cursor.moveToEndOfLine() - - # Remove trailing whitespace from the current line - scanRange = @cursor.getCurrentLineBufferRange() - trailingWhitespaceRange = null - @editor.scanInBufferRange /[ \t]+$/, scanRange, ({range}) -> - trailingWhitespaceRange = range - if trailingWhitespaceRange? - @setBufferRange(trailingWhitespaceRange) - @deleteSelectedText() - - currentRow = selectedRange.start.row - nextRow = currentRow + 1 - insertSpace = nextRow <= @editor.buffer.getLastRow() and - @editor.buffer.lineLengthForRow(nextRow) > 0 and - @editor.buffer.lineLengthForRow(currentRow) > 0 - @insertText(' ') if insertSpace - - @cursor.moveToEndOfLine() - - # Remove leading whitespace from the line below - @modifySelection => - @cursor.moveRight() - @cursor.moveToFirstCharacterOfLine() - @deleteSelectedText() - - @cursor.moveLeft() if insertSpace - - if joinMarker? - newSelectedRange = joinMarker.getBufferRange() - @setBufferRange(newSelectedRange) - joinMarker.destroy() - - # Public: Removes one level of indent from the currently selected rows. - outdentSelectedRows: -> - [start, end] = @getBufferRowRange() - buffer = @editor.buffer - leadingTabRegex = new RegExp("^( {1,#{@editor.getTabLength()}}|\t)") - for row in [start..end] - if matchLength = buffer.lineForRow(row).match(leadingTabRegex)?[0].length - buffer.delete [[row, 0], [row, matchLength]] - return - - # Public: Sets the indentation level of all selected rows to values suggested - # by the relevant grammars. - autoIndentSelectedRows: -> - [start, end] = @getBufferRowRange() - @editor.autoIndentBufferRows(start, end) - - # Public: Wraps the selected lines in comments if they aren't currently part - # of a comment. - # - # Removes the comment if they are currently wrapped in a comment. - toggleLineComments: -> - @editor.toggleLineCommentsForBufferRows(@getBufferRowRange()...) - - # Public: Cuts the selection until the end of the screen line. - cutToEndOfLine: (maintainClipboard) -> - @selectToEndOfLine() if @isEmpty() - @cut(maintainClipboard) - - # Public: Cuts the selection until the end of the buffer line. - cutToEndOfBufferLine: (maintainClipboard) -> - @selectToEndOfBufferLine() if @isEmpty() - @cut(maintainClipboard) - - # Public: Copies the selection to the clipboard and then deletes it. - # - # * `maintainClipboard` {Boolean} (default: false) See {::copy} - # * `fullLine` {Boolean} (default: false) See {::copy} - cut: (maintainClipboard=false, fullLine=false) -> - @copy(maintainClipboard, fullLine) - @delete() - - # Public: Copies the current selection to the clipboard. - # - # * `maintainClipboard` {Boolean} if `true`, a specific metadata property - # is created to store each content copied to the clipboard. The clipboard - # `text` still contains the concatenation of the clipboard with the - # current selection. (default: false) - # * `fullLine` {Boolean} if `true`, the copied text will always be pasted - # at the beginning of the line containing the cursor, regardless of the - # cursor's horizontal position. (default: false) - copy: (maintainClipboard=false, fullLine=false) -> - return if @isEmpty() - {start, end} = @getBufferRange() - selectionText = @editor.getTextInRange([start, end]) - precedingText = @editor.getTextInRange([[start.row, 0], start]) - startLevel = @editor.indentLevelForLine(precedingText) - - if maintainClipboard - {text: clipboardText, metadata} = @editor.constructor.clipboard.readWithMetadata() - metadata ?= {} - unless metadata.selections? - metadata.selections = [{ - text: clipboardText, - indentBasis: metadata.indentBasis, - fullLine: metadata.fullLine, - }] - metadata.selections.push({ - text: selectionText, - indentBasis: startLevel, - fullLine: fullLine - }) - @editor.constructor.clipboard.write([clipboardText, selectionText].join("\n"), metadata) - else - @editor.constructor.clipboard.write(selectionText, { - indentBasis: startLevel, - fullLine: fullLine - }) - - # Public: Creates a fold containing the current selection. - fold: -> - range = @getBufferRange() - unless range.isEmpty() - @editor.foldBufferRange(range) - @cursor.setBufferPosition(range.end) - - # Private: Increase the indentation level of the given text by given number - # of levels. Leaves the first line unchanged. - adjustIndent: (lines, indentAdjustment) -> - for line, i in lines - if indentAdjustment is 0 or line is '' - continue - else if indentAdjustment > 0 - lines[i] = @editor.buildIndentString(indentAdjustment) + line - else - currentIndentLevel = @editor.indentLevelForLine(lines[i]) - indentLevel = Math.max(0, currentIndentLevel + indentAdjustment) - lines[i] = line.replace(/^[\t ]+/, @editor.buildIndentString(indentLevel)) - return - - # Indent the current line(s). - # - # If the selection is empty, indents the current line if the cursor precedes - # non-whitespace characters, and otherwise inserts a tab. If the selection is - # non empty, calls {::indentSelectedRows}. - # - # * `options` (optional) {Object} with the keys: - # * `autoIndent` If `true`, the line is indented to an automatically-inferred - # level. Otherwise, {TextEditor::getTabText} is inserted. - indent: ({autoIndent}={}) -> - {row} = @cursor.getBufferPosition() - - if @isEmpty() - @cursor.skipLeadingWhitespace() - desiredIndent = @editor.suggestedIndentForBufferRow(row) - delta = desiredIndent - @cursor.getIndentLevel() - - if autoIndent and delta > 0 - delta = Math.max(delta, 1) unless @editor.getSoftTabs() - @insertText(@editor.buildIndentString(delta)) - else - @insertText(@editor.buildIndentString(1, @cursor.getBufferColumn())) - else - @indentSelectedRows() - - # Public: If the selection spans multiple rows, indent all of them. - indentSelectedRows: -> - [start, end] = @getBufferRowRange() - for row in [start..end] - @editor.buffer.insert([row, 0], @editor.getTabText()) unless @editor.buffer.lineLengthForRow(row) is 0 - return - - ### - Section: Managing multiple selections - ### - - # Public: Moves the selection down one row. - addSelectionBelow: -> - range = @getGoalScreenRange().copy() - nextRow = range.end.row + 1 - - for row in [nextRow..@editor.getLastScreenRow()] - range.start.row = row - range.end.row = row - clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) - - if range.isEmpty() - continue if range.end.column > 0 and clippedRange.end.column is 0 - else - continue if clippedRange.isEmpty() - - selection = @editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) - break - - return - - # Public: Moves the selection up one row. - addSelectionAbove: -> - range = @getGoalScreenRange().copy() - previousRow = range.end.row - 1 - - for row in [previousRow..0] - range.start.row = row - range.end.row = row - clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) - - if range.isEmpty() - continue if range.end.column > 0 and clippedRange.end.column is 0 - else - continue if clippedRange.isEmpty() - - selection = @editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) - break - - return - - # Public: Combines the given selection into this selection and then destroys - # the given selection. - # - # * `otherSelection` A {Selection} to merge with. - # * `options` (optional) {Object} options matching those found in {::setBufferRange}. - merge: (otherSelection, options = {}) -> - myGoalScreenRange = @getGoalScreenRange() - otherGoalScreenRange = otherSelection.getGoalScreenRange() - - if myGoalScreenRange? and otherGoalScreenRange? - options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange) - else - options.goalScreenRange = myGoalScreenRange ? otherGoalScreenRange - - @setBufferRange(@getBufferRange().union(otherSelection.getBufferRange()), Object.assign(autoscroll: false, options)) - otherSelection.destroy() - - ### - Section: Comparing to other selections - ### - - # Public: Compare this selection's buffer range to another selection's buffer - # range. - # - # See {Range::compare} for more details. - # - # * `otherSelection` A {Selection} to compare against - compare: (otherSelection) -> - @marker.compare(otherSelection.marker) - - ### - Section: Private Utilities - ### - - setGoalScreenRange: (range) -> - @goalScreenRange = Range.fromObject(range) - - getGoalScreenRange: -> - @goalScreenRange ? @getScreenRange() - - markerDidChange: (e) -> - {oldHeadBufferPosition, oldTailBufferPosition, newHeadBufferPosition} = e - {oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e - {textChanged} = e - - unless oldHeadScreenPosition.isEqual(newHeadScreenPosition) - @cursor.goalColumn = null - cursorMovedEvent = { - oldBufferPosition: oldHeadBufferPosition - oldScreenPosition: oldHeadScreenPosition - newBufferPosition: newHeadBufferPosition - newScreenPosition: newHeadScreenPosition - textChanged: textChanged - cursor: @cursor - } - @cursor.emitter.emit('did-change-position', cursorMovedEvent) - @editor.cursorMoved(cursorMovedEvent) - - @emitter.emit 'did-change-range' - @editor.selectionRangeChanged( - oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition) - oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition) - newBufferRange: @getBufferRange() - newScreenRange: @getScreenRange() - selection: this - ) - - markerDidDestroy: -> - return if @editor.isDestroyed() - - @destroyed = true - @cursor.destroyed = true - - @editor.removeSelection(this) - - @cursor.emitter.emit 'did-destroy' - @emitter.emit 'did-destroy' - - @cursor.emitter.dispose() - @emitter.dispose() - - finalize: -> - @initialScreenRange = null unless @initialScreenRange?.isEqual(@getScreenRange()) - if @isEmpty() - @wordwise = false - @linewise = false - - autoscroll: (options) -> - if @marker.hasTail() - @editor.scrollToScreenRange(@getScreenRange(), Object.assign({reversed: @isReversed()}, options)) - else - @cursor.autoscroll(options) - - clearAutoscroll: -> - - modifySelection: (fn) -> - @retainSelection = true - @plantTail() - fn() - @retainSelection = false - - # Sets the marker's tail to the same position as the marker's head. - # - # This only works if there isn't already a tail position. - # - # Returns a {Point} representing the new tail position. - plantTail: -> - @marker.plantTail() diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 000000000..20561fd64 --- /dev/null +++ b/src/selection.js @@ -0,0 +1,975 @@ +const {Point, Range} = require('text-buffer') +const {pick} = require('underscore-plus') +const {Emitter} = require('event-kit') + +const NonWhitespaceRegExp = /\S/ +let nextId = 0 + +// Extended: Represents a selection in the {TextEditor}. +module.exports = +class Selection { + constructor ({cursor, marker, editor, id}) { + this.id = (id != null) ? id : nextId++ + this.cursor = cursor + this.marker = marker + this.editor = editor + this.emitter = new Emitter() + this.initialScreenRange = null + this.wordwise = false + this.cursor.selection = this + this.decoration = this.editor.decorateMarker(this.marker, {type: 'highlight', class: 'selection'}) + this.marker.onDidChange(e => this.markerDidChange(e)) + this.marker.onDidDestroy(() => this.markerDidDestroy()) + } + + destroy () { + this.marker.destroy() + } + + isLastSelection () { + return this === this.editor.getLastSelection() + } + + /* + Section: Event Subscription + */ + + // Extended: Calls your `callback` when the selection was moved. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferRange` {Range} + // * `oldScreenRange` {Range} + // * `newBufferRange` {Range} + // * `newScreenRange` {Range} + // * `selection` {Selection} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeRange (callback) { + return this.emitter.on('did-change-range', callback) + } + + // Extended: Calls your `callback` when the selection was 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 the selection range + */ + + // Public: Returns the screen {Range} for the selection. + getScreenRange () { + return this.marker.getScreenRange() + } + + // Public: Modifies the screen range for the selection. + // + // * `screenRange` The new {Range} to use. + // * `options` (optional) {Object} options matching those found in {::setBufferRange}. + setScreenRange (screenRange, options) { + return this.setBufferRange(this.editor.bufferRangeForScreenRange(screenRange), options) + } + + // Public: Returns the buffer {Range} for the selection. + getBufferRange () { + return this.marker.getBufferRange() + } + + // Public: Modifies the buffer {Range} for the selection. + // + // * `bufferRange` The new {Range} to select. + // * `options` (optional) {Object} with the keys: + // * `preserveFolds` if `true`, the fold settings are preserved after the + // selection moves. + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // range. Defaults to `true` if this is the most recently added selection, + // `false` otherwise. + setBufferRange (bufferRange, options = {}) { + bufferRange = Range.fromObject(bufferRange) + if (options.reversed == null) options.reversed = this.isReversed() + if (!options.preserveFolds) this.editor.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) + this.modifySelection(() => { + const needsFlash = options.flash + options.flash = null + this.marker.setBufferRange(bufferRange, options) + const autoscroll = options.autoscroll != null ? options.autoscroll : this.isLastSelection() + if (autoscroll) this.autoscroll() + if (needsFlash) this.decoration.flash('flash', this.editor.selectionFlashDuration) + }) + } + + // Public: Returns the starting and ending buffer rows the selection is + // highlighting. + // + // Returns an {Array} of two {Number}s: the starting row, and the ending row. + getBufferRowRange () { + const range = this.getBufferRange() + const start = range.start.row + let end = range.end.row + if (range.end.column === 0) end = Math.max(start, end - 1) + return [start, end] + } + + getTailScreenPosition () { + return this.marker.getTailScreenPosition() + } + + getTailBufferPosition () { + return this.marker.getTailBufferPosition() + } + + getHeadScreenPosition () { + return this.marker.getHeadScreenPosition() + } + + getHeadBufferPosition () { + return this.marker.getHeadBufferPosition() + } + + /* + Section: Info about the selection + */ + + // Public: Determines if the selection contains anything. + isEmpty () { + return this.getBufferRange().isEmpty() + } + + // Public: Determines if the ending position of a marker is greater than the + // starting position. + // + // This can happen when, for example, you highlight text "up" in a {TextBuffer}. + isReversed () { + return this.marker.isReversed() + } + + // Public: Returns whether the selection is a single line or not. + isSingleScreenLine () { + return this.getScreenRange().isSingleLine() + } + + // Public: Returns the text in the selection. + getText () { + return this.editor.buffer.getTextInRange(this.getBufferRange()) + } + + // Public: Identifies if a selection intersects with a given buffer range. + // + // * `bufferRange` A {Range} to check against. + // + // Returns a {Boolean} + intersectsBufferRange (bufferRange) { + return this.getBufferRange().intersectsWith(bufferRange) + } + + intersectsScreenRowRange (startRow, endRow) { + return this.getScreenRange().intersectsRowRange(startRow, endRow) + } + + intersectsScreenRow (screenRow) { + return this.getScreenRange().intersectsRow(screenRow) + } + + // Public: Identifies if a selection intersects with another selection. + // + // * `otherSelection` A {Selection} to check against. + // + // Returns a {Boolean} + intersectsWith (otherSelection, exclusive) { + return this.getBufferRange().intersectsWith(otherSelection.getBufferRange(), exclusive) + } + + /* + Section: Modifying the selected range + */ + + // Public: Clears the selection, moving the marker to the head. + // + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // range. Defaults to `true` if this is the most recently added selection, + // `false` otherwise. + clear (options) { + this.goalScreenRange = null + if (!this.retainSelection) this.marker.clearTail() + const autoscroll = options && options.autoscroll != null + ? options.autoscroll + : this.isLastSelection() + if (autoscroll) this.autoscroll() + this.finalize() + } + + // Public: Selects the text from the current cursor position to a given screen + // position. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToScreenPosition (position, options) { + position = Point.fromObject(position) + + this.modifySelection(() => { + if (this.initialScreenRange) { + if (position.isLessThan(this.initialScreenRange.start)) { + this.marker.setScreenRange([position, this.initialScreenRange.end], {reversed: true}) + } else { + this.marker.setScreenRange([this.initialScreenRange.start, position], {reversed: false}) + } + } else { + this.cursor.setScreenPosition(position, options) + } + + if (this.linewise) { + this.expandOverLine(options) + } else if (this.wordwise) { + this.expandOverWord(options) + } + }) + } + + // Public: Selects the text from the current cursor position to a given buffer + // position. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToBufferPosition (position) { + this.modifySelection(() => this.cursor.setBufferPosition(position)) + } + + // Public: Selects the text one position right of the cursor. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + selectRight (columnCount) { + this.modifySelection(() => this.cursor.moveRight(columnCount)) + } + + // Public: Selects the text one position left of the cursor. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + selectLeft (columnCount) { + this.modifySelection(() => this.cursor.moveLeft(columnCount)) + } + + // Public: Selects all the text one position above the cursor. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + selectUp (rowCount) { + this.modifySelection(() => this.cursor.moveUp(rowCount)) + } + + // Public: Selects all the text one position below the cursor. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + selectDown (rowCount) { + this.modifySelection(() => this.cursor.moveDown(rowCount)) + } + + // Public: Selects all the text from the current cursor position to the top of + // the buffer. + selectToTop () { + this.modifySelection(() => this.cursor.moveToTop()) + } + + // Public: Selects all the text from the current cursor position to the bottom + // of the buffer. + selectToBottom () { + this.modifySelection(() => this.cursor.moveToBottom()) + } + + // Public: Selects all the text in the buffer. + selectAll () { + this.setBufferRange(this.editor.buffer.getRange(), {autoscroll: false}) + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the line. + selectToBeginningOfLine () { + this.modifySelection(() => this.cursor.moveToBeginningOfLine()) + } + + // Public: Selects all the text from the current cursor position to the first + // character of the line. + selectToFirstCharacterOfLine () { + this.modifySelection(() => this.cursor.moveToFirstCharacterOfLine()) + } + + // Public: Selects all the text from the current cursor position to the end of + // the screen line. + selectToEndOfLine () { + this.modifySelection(() => this.cursor.moveToEndOfScreenLine()) + } + + // Public: Selects all the text from the current cursor position to the end of + // the buffer line. + selectToEndOfBufferLine () { + this.modifySelection(() => this.cursor.moveToEndOfLine()) + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the word. + selectToBeginningOfWord () { + this.modifySelection(() => this.cursor.moveToBeginningOfWord()) + } + + // Public: Selects all the text from the current cursor position to the end of + // the word. + selectToEndOfWord () { + this.modifySelection(() => this.cursor.moveToEndOfWord()) + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the next word. + selectToBeginningOfNextWord () { + this.modifySelection(() => this.cursor.moveToBeginningOfNextWord()) + } + + // Public: Selects text to the previous word boundary. + selectToPreviousWordBoundary () { + this.modifySelection(() => this.cursor.moveToPreviousWordBoundary()) + } + + // Public: Selects text to the next word boundary. + selectToNextWordBoundary () { + this.modifySelection(() => this.cursor.moveToNextWordBoundary()) + } + + // Public: Selects text to the previous subword boundary. + selectToPreviousSubwordBoundary () { + this.modifySelection(() => this.cursor.moveToPreviousSubwordBoundary()) + } + + // Public: Selects text to the next subword boundary. + selectToNextSubwordBoundary () { + this.modifySelection(() => this.cursor.moveToNextSubwordBoundary()) + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the next paragraph. + selectToBeginningOfNextParagraph () { + this.modifySelection(() => this.cursor.moveToBeginningOfNextParagraph()) + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the previous paragraph. + selectToBeginningOfPreviousParagraph () { + this.modifySelection(() => this.cursor.moveToBeginningOfPreviousParagraph()) + } + + // Public: Modifies the selection to encompass the current word. + // + // Returns a {Range}. + selectWord (options = {}) { + if (this.cursor.isSurroundedByWhitespace()) options.wordRegex = /[\t ]*/ + if (this.cursor.isBetweenWordAndNonWord()) { + options.includeNonWordCharacters = false + } + + this.setBufferRange(this.cursor.getCurrentWordBufferRange(options), options) + this.wordwise = true + this.initialScreenRange = this.getScreenRange() + } + + // Public: Expands the newest selection to include the entire word on which + // the cursors rests. + expandOverWord (options) { + this.setBufferRange(this.getBufferRange().union(this.cursor.getCurrentWordBufferRange()), {autoscroll: false}) + const autoscroll = options && options.autoscroll != null ? options.autoscroll : this.isLastSelection() + if (autoscroll) this.cursor.autoscroll() + } + + // Public: Selects an entire line in the buffer. + // + // * `row` The line {Number} to select (default: the row of the cursor). + selectLine (row, options) { + if (row != null) { + this.setBufferRange(this.editor.bufferRangeForBufferRow(row, {includeNewline: true}), options) + } else { + const startRange = this.editor.bufferRangeForBufferRow(this.marker.getStartBufferPosition().row) + const endRange = this.editor.bufferRangeForBufferRow(this.marker.getEndBufferPosition().row, {includeNewline: true}) + this.setBufferRange(startRange.union(endRange), options) + } + + this.linewise = true + this.wordwise = false + this.initialScreenRange = this.getScreenRange() + } + + // Public: Expands the newest selection to include the entire line on which + // the cursor currently rests. + // + // It also includes the newline character. + expandOverLine (options) { + const range = this.getBufferRange().union(this.cursor.getCurrentLineBufferRange({includeNewline: true})) + this.setBufferRange(range, {autoscroll: false}) + const autoscroll = options && options.autoscroll != null ? options.autoscroll : this.isLastSelection() + if (autoscroll) this.cursor.autoscroll() + } + + /* + Section: Modifying the selected text + */ + + // Public: Replaces text at the current selection. + // + // * `text` A {String} representing the text to add + // * `options` (optional) {Object} with keys: + // * `select` If `true`, selects the newly added text. + // * `autoIndent` If `true`, indents all inserted text appropriately. + // * `autoIndentNewline` If `true`, indent newline appropriately. + // * `autoDecreaseIndent` If `true`, decreases indent level appropriately + // (for example, when a closing bracket is inserted). + // * `preserveTrailingLineIndentation` By default, when pasting multiple + // lines, Atom attempts to preserve the relative indent level between the + // first line and trailing lines, even if the indent level of the first + // line has changed from the copied text. If this option is `true`, this + // behavior is suppressed. + // level between the first lines and the trailing lines. + // * `normalizeLineEndings` (optional) {Boolean} (default: true) + // * `undo` If `skip`, skips the undo stack for this operation. + insertText (text, options = {}) { + let desiredIndentLevel, indentAdjustment + const oldBufferRange = this.getBufferRange() + const wasReversed = this.isReversed() + this.clear(options) + + let autoIndentFirstLine = false + const precedingText = this.editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start]) + const remainingLines = text.split('\n') + const firstInsertedLine = remainingLines.shift() + + if (options.indentBasis != null && !options.preserveTrailingLineIndentation) { + indentAdjustment = this.editor.indentLevelForLine(precedingText) - options.indentBasis + this.adjustIndent(remainingLines, indentAdjustment) + } + + const textIsAutoIndentable = (text === '\n') || (text === '\r\n') || NonWhitespaceRegExp.test(text) + if (options.autoIndent && textIsAutoIndentable && !NonWhitespaceRegExp.test(precedingText) && (remainingLines.length > 0)) { + autoIndentFirstLine = true + const firstLine = precedingText + firstInsertedLine + desiredIndentLevel = this.editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) + indentAdjustment = desiredIndentLevel - this.editor.indentLevelForLine(firstLine) + this.adjustIndent(remainingLines, indentAdjustment) + } + + text = firstInsertedLine + if (remainingLines.length > 0) text += `\n${remainingLines.join('\n')}` + + const newBufferRange = this.editor.buffer.setTextInRange(oldBufferRange, text, pick(options, 'undo', 'normalizeLineEndings')) + + if (options.select) { + this.setBufferRange(newBufferRange, {reversed: wasReversed}) + } else { + if (wasReversed) this.cursor.setBufferPosition(newBufferRange.end) + } + + if (autoIndentFirstLine) { + this.editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel) + } + + if (options.autoIndentNewline && (text === '\n')) { + this.editor.autoIndentBufferRow(newBufferRange.end.row, {preserveLeadingWhitespace: true, skipBlankLines: false}) + } else if (options.autoDecreaseIndent && NonWhitespaceRegExp.test(text)) { + this.editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row) + } + + const autoscroll = options.autoscroll != null ? options.autoscroll : this.isLastSelection() + if (autoscroll) this.autoscroll() + + return newBufferRange + } + + // Public: Removes the first character before the selection if the selection + // is empty otherwise it deletes the selection. + backspace () { + if (this.isEmpty()) this.selectLeft() + this.deleteSelectedText() + } + + // Public: Removes the selection or, if nothing is selected, then all + // characters from the start of the selection back to the previous word + // boundary. + deleteToPreviousWordBoundary () { + if (this.isEmpty()) this.selectToPreviousWordBoundary() + this.deleteSelectedText() + } + + // Public: Removes the selection or, if nothing is selected, then all + // characters from the start of the selection up to the next word + // boundary. + deleteToNextWordBoundary () { + if (this.isEmpty()) this.selectToNextWordBoundary() + this.deleteSelectedText() + } + + // Public: Removes from the start of the selection to the beginning of the + // current word if the selection is empty otherwise it deletes the selection. + deleteToBeginningOfWord () { + if (this.isEmpty()) this.selectToBeginningOfWord() + this.deleteSelectedText() + } + + // Public: Removes from the beginning of the line which the selection begins on + // all the way through to the end of the selection. + deleteToBeginningOfLine () { + if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) { + this.selectLeft() + } else { + this.selectToBeginningOfLine() + } + this.deleteSelectedText() + } + + // Public: Removes the selection or the next character after the start of the + // selection if the selection is empty. + delete () { + if (this.isEmpty()) this.selectRight() + this.deleteSelectedText() + } + + // Public: If the selection is empty, removes all text from the cursor to the + // end of the line. If the cursor is already at the end of the line, it + // removes the following newline. If the selection isn't empty, only deletes + // the contents of the selection. + deleteToEndOfLine () { + if (this.isEmpty()) { + if (this.cursor.isAtEndOfLine()) { + this.delete() + return + } + this.selectToEndOfLine() + } + this.deleteSelectedText() + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + deleteToEndOfWord () { + if (this.isEmpty()) this.selectToEndOfWord() + this.deleteSelectedText() + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + deleteToBeginningOfSubword () { + if (this.isEmpty()) this.selectToPreviousSubwordBoundary() + this.deleteSelectedText() + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + deleteToEndOfSubword () { + if (this.isEmpty()) this.selectToNextSubwordBoundary() + this.deleteSelectedText() + } + + // Public: Removes only the selected text. + deleteSelectedText () { + const bufferRange = this.getBufferRange() + if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange) + if (this.cursor) this.cursor.setBufferPosition(bufferRange.start) + } + + // Public: Removes the line at the beginning of the selection if the selection + // is empty unless the selection spans multiple lines in which case all lines + // are removed. + deleteLine () { + if (this.isEmpty()) { + const start = this.cursor.getScreenRow() + const range = this.editor.bufferRowsForScreenRows(start, start + 1) + if (range[1] > range[0]) { + this.editor.buffer.deleteRows(range[0], range[1] - 1) + } else { + this.editor.buffer.deleteRow(range[0]) + } + } else { + const range = this.getBufferRange() + const start = range.start.row + let end = range.end.row + if (end !== this.editor.buffer.getLastRow() && range.end.column === 0) end-- + this.editor.buffer.deleteRows(start, end) + } + } + + // Public: Joins the current line with the one below it. Lines will + // be separated by a single space. + // + // If there selection spans more than one line, all the lines are joined together. + joinLines () { + let joinMarker + const selectedRange = this.getBufferRange() + if (selectedRange.isEmpty()) { + if (selectedRange.start.row === this.editor.buffer.getLastRow()) return + } else { + joinMarker = this.editor.markBufferRange(selectedRange, {invalidate: 'never'}) + } + + const rowCount = Math.max(1, selectedRange.getRowCount() - 1) + for (let i = 0; i < rowCount; i++) { + this.cursor.setBufferPosition([selectedRange.start.row]) + this.cursor.moveToEndOfLine() + + // Remove trailing whitespace from the current line + const scanRange = this.cursor.getCurrentLineBufferRange() + let trailingWhitespaceRange = null + this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({range}) => trailingWhitespaceRange = range) + if (trailingWhitespaceRange) { + this.setBufferRange(trailingWhitespaceRange) + this.deleteSelectedText() + } + + const currentRow = selectedRange.start.row + const nextRow = currentRow + 1 + const insertSpace = + (nextRow <= this.editor.buffer.getLastRow()) && + (this.editor.buffer.lineLengthForRow(nextRow) > 0) && + (this.editor.buffer.lineLengthForRow(currentRow) > 0) + if (insertSpace) this.insertText(' ') + + this.cursor.moveToEndOfLine() + + // Remove leading whitespace from the line below + this.modifySelection(() => { + this.cursor.moveRight() + this.cursor.moveToFirstCharacterOfLine() + }) + this.deleteSelectedText() + + if (insertSpace) this.cursor.moveLeft() + } + + if (joinMarker) { + const newSelectedRange = joinMarker.getBufferRange() + this.setBufferRange(newSelectedRange) + joinMarker.destroy() + } + } + + // Public: Removes one level of indent from the currently selected rows. + outdentSelectedRows () { + const [start, end] = this.getBufferRowRange() + const {buffer} = this.editor + const leadingTabRegex = new RegExp(`^( {1,${this.editor.getTabLength()}}|\t)`) + for (let row = start; row <= end; row++) { + const match = buffer.lineForRow(row).match(leadingTabRegex) + if (match && match[0].length > 0) { + buffer.delete([[row, 0], [row, match[0].length]]) + } + } + } + + // Public: Sets the indentation level of all selected rows to values suggested + // by the relevant grammars. + autoIndentSelectedRows () { + const [start, end] = this.getBufferRowRange() + return this.editor.autoIndentBufferRows(start, end) + } + + // Public: Wraps the selected lines in comments if they aren't currently part + // of a comment. + // + // Removes the comment if they are currently wrapped in a comment. + toggleLineComments () { + this.editor.toggleLineCommentsForBufferRows(...(this.getBufferRowRange() || [])) + } + + // Public: Cuts the selection until the end of the screen line. + cutToEndOfLine (maintainClipboard) { + if (this.isEmpty()) this.selectToEndOfLine() + return this.cut(maintainClipboard) + } + + // Public: Cuts the selection until the end of the buffer line. + cutToEndOfBufferLine (maintainClipboard) { + if (this.isEmpty()) this.selectToEndOfBufferLine() + this.cut(maintainClipboard) + } + + // Public: Copies the selection to the clipboard and then deletes it. + // + // * `maintainClipboard` {Boolean} (default: false) See {::copy} + // * `fullLine` {Boolean} (default: false) See {::copy} + cut (maintainClipboard = false, fullLine = false) { + this.copy(maintainClipboard, fullLine) + this.delete() + } + + // Public: Copies the current selection to the clipboard. + // + // * `maintainClipboard` {Boolean} if `true`, a specific metadata property + // is created to store each content copied to the clipboard. The clipboard + // `text` still contains the concatenation of the clipboard with the + // current selection. (default: false) + // * `fullLine` {Boolean} if `true`, the copied text will always be pasted + // at the beginning of the line containing the cursor, regardless of the + // cursor's horizontal position. (default: false) + copy (maintainClipboard = false, fullLine = false) { + if (this.isEmpty()) return + const {start, end} = this.getBufferRange() + const selectionText = this.editor.getTextInRange([start, end]) + const precedingText = this.editor.getTextInRange([[start.row, 0], start]) + const startLevel = this.editor.indentLevelForLine(precedingText) + + if (maintainClipboard) { + let {text: clipboardText, metadata} = this.editor.constructor.clipboard.readWithMetadata() + if (!metadata) metadata = {} + if (!metadata.selections) { + metadata.selections = [{ + text: clipboardText, + indentBasis: metadata.indentBasis, + fullLine: metadata.fullLine + }] + } + metadata.selections.push({ + text: selectionText, + indentBasis: startLevel, + fullLine + }) + this.editor.constructor.clipboard.write([clipboardText, selectionText].join('\n'), metadata) + } else { + this.editor.constructor.clipboard.write(selectionText, { + indentBasis: startLevel, + fullLine + }) + } + } + + // Public: Creates a fold containing the current selection. + fold () { + const range = this.getBufferRange() + if (!range.isEmpty()) { + this.editor.foldBufferRange(range) + this.cursor.setBufferPosition(range.end) + } + } + + // Private: Increase the indentation level of the given text by given number + // of levels. Leaves the first line unchanged. + adjustIndent (lines, indentAdjustment) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (indentAdjustment === 0 || line === '') { + continue + } else if (indentAdjustment > 0) { + lines[i] = this.editor.buildIndentString(indentAdjustment) + line + } else { + const currentIndentLevel = this.editor.indentLevelForLine(lines[i]) + const indentLevel = Math.max(0, currentIndentLevel + indentAdjustment) + lines[i] = line.replace(/^[\t ]+/, this.editor.buildIndentString(indentLevel)) + } + } + } + + // Indent the current line(s). + // + // If the selection is empty, indents the current line if the cursor precedes + // non-whitespace characters, and otherwise inserts a tab. If the selection is + // non empty, calls {::indentSelectedRows}. + // + // * `options` (optional) {Object} with the keys: + // * `autoIndent` If `true`, the line is indented to an automatically-inferred + // level. Otherwise, {TextEditor::getTabText} is inserted. + indent ({autoIndent} = {}) { + const {row} = this.cursor.getBufferPosition() + + if (this.isEmpty()) { + this.cursor.skipLeadingWhitespace() + const desiredIndent = this.editor.suggestedIndentForBufferRow(row) + let delta = desiredIndent - this.cursor.getIndentLevel() + + if (autoIndent && delta > 0) { + if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1) + this.insertText(this.editor.buildIndentString(delta)) + } else { + this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn())) + } + } else { + this.indentSelectedRows() + } + } + + // Public: If the selection spans multiple rows, indent all of them. + indentSelectedRows () { + const [start, end] = this.getBufferRowRange() + for (let row = start; row <= end; row++) { + if (this.editor.buffer.lineLengthForRow(row) !== 0) { + this.editor.buffer.insert([row, 0], this.editor.getTabText()) + } + } + } + + /* + Section: Managing multiple selections + */ + + // Public: Moves the selection down one row. + addSelectionBelow () { + const range = this.getGoalScreenRange().copy() + const nextRow = range.end.row + 1 + + for (let row = nextRow, end = this.editor.getLastScreenRow(); row <= end; row++) { + range.start.row = row + range.end.row = row + const clippedRange = this.editor.clipScreenRange(range, {skipSoftWrapIndentation: true}) + + if (range.isEmpty()) { + if (range.end.column > 0 && clippedRange.end.column === 0) continue + } else { + if (clippedRange.isEmpty()) continue + } + + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + break + } + } + + // Public: Moves the selection up one row. + addSelectionAbove () { + const range = this.getGoalScreenRange().copy() + const previousRow = range.end.row - 1 + + for (let row = previousRow; row >= 0; row--) { + range.start.row = row + range.end.row = row + const clippedRange = this.editor.clipScreenRange(range, {skipSoftWrapIndentation: true}) + + if (range.isEmpty()) { + if (range.end.column > 0 && clippedRange.end.column === 0) continue + } else { + if (clippedRange.isEmpty()) continue + } + + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + break + } + } + + // Public: Combines the given selection into this selection and then destroys + // the given selection. + // + // * `otherSelection` A {Selection} to merge with. + // * `options` (optional) {Object} options matching those found in {::setBufferRange}. + merge (otherSelection, options = {}) { + const myGoalScreenRange = this.getGoalScreenRange() + const otherGoalScreenRange = otherSelection.getGoalScreenRange() + + if (myGoalScreenRange && otherGoalScreenRange) { + options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange) + } else { + options.goalScreenRange = myGoalScreenRange || otherGoalScreenRange + } + + const bufferRange = this.getBufferRange().union(otherSelection.getBufferRange()) + this.setBufferRange(bufferRange, Object.assign({autoscroll: false}, options)) + otherSelection.destroy() + } + + /* + Section: Comparing to other selections + */ + + // Public: Compare this selection's buffer range to another selection's buffer + // range. + // + // See {Range::compare} for more details. + // + // * `otherSelection` A {Selection} to compare against + compare (otherSelection) { + return this.marker.compare(otherSelection.marker) + } + + /* + Section: Private Utilities + */ + + setGoalScreenRange (range) { + return this.goalScreenRange = Range.fromObject(range) + } + + getGoalScreenRange () { + return this.goalScreenRange || this.getScreenRange() + } + + markerDidChange (e) { + const {oldHeadBufferPosition, oldTailBufferPosition, newHeadBufferPosition} = e + const {oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e + const {textChanged} = e + + if (!oldHeadScreenPosition.isEqual(newHeadScreenPosition)) { + this.cursor.goalColumn = null + const cursorMovedEvent = { + oldBufferPosition: oldHeadBufferPosition, + oldScreenPosition: oldHeadScreenPosition, + newBufferPosition: newHeadBufferPosition, + newScreenPosition: newHeadScreenPosition, + textChanged, + cursor: this.cursor + } + this.cursor.emitter.emit('did-change-position', cursorMovedEvent) + this.editor.cursorMoved(cursorMovedEvent) + } + + this.emitter.emit('did-change-range') + this.editor.selectionRangeChanged({ + oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition), + oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition), + newBufferRange: this.getBufferRange(), + newScreenRange: this.getScreenRange(), + selection: this + }) + } + + markerDidDestroy () { + if (this.editor.isDestroyed()) return + + this.destroyed = true + this.cursor.destroyed = true + + this.editor.removeSelection(this) + + this.cursor.emitter.emit('did-destroy') + this.emitter.emit('did-destroy') + + this.cursor.emitter.dispose() + this.emitter.dispose() + } + + finalize () { + if (!this.initialScreenRange || !this.initialScreenRange.isEqual(this.getScreenRange())) { + this.initialScreenRange = null + } + if (this.isEmpty()) { + this.wordwise = false + this.linewise = false + } + } + + autoscroll (options) { + if (this.marker.hasTail()) { + this.editor.scrollToScreenRange(this.getScreenRange(), Object.assign({reversed: this.isReversed()}, options)) + } else { + this.cursor.autoscroll(options) + } + } + + clearAutoscroll () {} + + modifySelection (fn) { + this.retainSelection = true + this.plantTail() + fn() + this.retainSelection = false + } + + // Sets the marker's tail to the same position as the marker's head. + // + // This only works if there isn't already a tail position. + // + // Returns a {Point} representing the new tail position. + plantTail () { + this.marker.plantTail() + } +} From 99f90af42729593e42337203b8c7c5f22c68801b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 2 Nov 2017 09:26:58 -0700 Subject: [PATCH 2/3] Convert Selection spec to JS --- spec/selection-spec.coffee | 128 ------------------------------ spec/selection-spec.js | 157 +++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 128 deletions(-) delete mode 100644 spec/selection-spec.coffee create mode 100644 spec/selection-spec.js diff --git a/spec/selection-spec.coffee b/spec/selection-spec.coffee deleted file mode 100644 index b0e65be30..000000000 --- a/spec/selection-spec.coffee +++ /dev/null @@ -1,128 +0,0 @@ -TextEditor = require '../src/text-editor' - -describe "Selection", -> - [buffer, editor, selection] = [] - - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - editor = new TextEditor({buffer: buffer, tabLength: 2}) - selection = editor.getLastSelection() - - afterEach -> - buffer.destroy() - - describe ".deleteSelectedText()", -> - describe "when nothing is selected", -> - it "deletes nothing", -> - selection.setBufferRange [[0, 3], [0, 3]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - describe "when one line is selected", -> - it "deletes selected text and clears the selection", -> - selection.setBufferRange [[0, 4], [0, 14]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "var = function () {" - - endOfLine = buffer.lineForRow(0).length - selection.setBufferRange [[0, 0], [0, endOfLine]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "" - - expect(selection.isEmpty()).toBeTruthy() - - describe "when multiple lines are selected", -> - it "deletes selected text and clears the selection", -> - selection.setBufferRange [[0, 1], [2, 39]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "v;" - expect(selection.isEmpty()).toBeTruthy() - - describe "when the cursor precedes the tail", -> - it "deletes selected text and clears the selection", -> - selection.cursor.setScreenPosition [0, 13] - selection.selectToScreenPosition [0, 4] - - selection.delete() - expect(buffer.lineForRow(0)).toBe "var = function () {" - expect(selection.isEmpty()).toBeTruthy() - - describe ".isReversed()", -> - it "returns true if the cursor precedes the tail", -> - selection.cursor.setScreenPosition([0, 20]) - selection.selectToScreenPosition([0, 10]) - expect(selection.isReversed()).toBeTruthy() - - selection.selectToScreenPosition([0, 25]) - expect(selection.isReversed()).toBeFalsy() - - describe ".selectLine(row)", -> - describe "when passed a row", -> - it "selects the specified row", -> - selection.setBufferRange([[2, 4], [3, 4]]) - selection.selectLine(5) - expect(selection.getBufferRange()).toEqual [[5, 0], [6, 0]] - - describe "when not passed a row", -> - it "selects all rows spanned by the selection", -> - selection.setBufferRange([[2, 4], [3, 4]]) - selection.selectLine() - expect(selection.getBufferRange()).toEqual [[2, 0], [4, 0]] - - describe "when only the selection's tail is moved (regression)", -> - it "notifies ::onDidChangeRange observers", -> - selection.setBufferRange([[2, 0], [2, 10]], reversed: true) - changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') - selection.onDidChangeRange changeScreenRangeHandler - - buffer.insert([2, 5], 'abc') - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the selection is destroyed", -> - it "destroys its marker", -> - selection.setBufferRange([[2, 0], [2, 10]]) - marker = selection.marker - selection.destroy() - expect(marker.isDestroyed()).toBeTruthy() - - describe ".insertText(text, options)", -> - it "allows pasting white space only lines when autoIndent is enabled", -> - selection.setBufferRange [[0, 0], [0, 0]] - selection.insertText(" \n \n\n", autoIndent: true) - expect(buffer.lineForRow(0)).toBe " " - expect(buffer.lineForRow(1)).toBe " " - expect(buffer.lineForRow(2)).toBe "" - - it "auto-indents if only a newline is inserted", -> - selection.setBufferRange [[2, 0], [3, 0]] - selection.insertText("\n", autoIndent: true) - expect(buffer.lineForRow(2)).toBe " " - - it "auto-indents if only a carriage return + newline is inserted", -> - selection.setBufferRange [[2, 0], [3, 0]] - selection.insertText("\r\n", autoIndent: true) - expect(buffer.lineForRow(2)).toBe " " - - it "does not adjust the indent of trailing lines if preserveTrailingLineIndentation is true", -> - selection.setBufferRange [[5, 0], [5, 0]] - selection.insertText(' foo\n bar\n', preserveTrailingLineIndentation: true, indentBasis: 1) - expect(buffer.lineForRow(6)).toBe(' bar') - - describe ".fold()", -> - it "folds the buffer range spanned by the selection", -> - selection.setBufferRange([[0, 3], [1, 6]]) - selection.fold() - - expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]]) - expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]]) - expect(editor.lineTextForScreenRow(0)).toBe "var#{editor.displayLayer.foldCharacter}sort = function(items) {" - expect(editor.isFoldedAtBufferRow(0)).toBe(true) - - it "doesn't create a fold when the selection is empty", -> - selection.setBufferRange([[0, 3], [0, 3]]) - selection.fold() - - expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]]) - expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]]) - expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {" - expect(editor.isFoldedAtBufferRow(0)).toBe(false) diff --git a/spec/selection-spec.js b/spec/selection-spec.js new file mode 100644 index 000000000..cb586da26 --- /dev/null +++ b/spec/selection-spec.js @@ -0,0 +1,157 @@ +const TextEditor = require('../src/text-editor') + +describe('Selection', () => { + let buffer, editor, selection + + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + editor = new TextEditor({buffer, tabLength: 2}) + selection = editor.getLastSelection() + }) + + afterEach(() => buffer.destroy()) + + describe('.deleteSelectedText()', () => { + describe('when nothing is selected', () => { + it('deletes nothing', () => { + selection.setBufferRange([[0, 3], [0, 3]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + }) + + describe('when one line is selected', () => { + it('deletes selected text and clears the selection', () => { + selection.setBufferRange([[0, 4], [0, 14]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('var = function () {') + + const endOfLine = buffer.lineForRow(0).length + selection.setBufferRange([[0, 0], [0, endOfLine]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('') + + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + describe('when multiple lines are selected', () => { + it('deletes selected text and clears the selection', () => { + selection.setBufferRange([[0, 1], [2, 39]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('v;') + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + describe('when the cursor precedes the tail', () => { + it('deletes selected text and clears the selection', () => { + selection.cursor.setScreenPosition([0, 13]) + selection.selectToScreenPosition([0, 4]) + + selection.delete() + expect(buffer.lineForRow(0)).toBe('var = function () {') + expect(selection.isEmpty()).toBeTruthy() + }) + }) + }) + + describe('.isReversed()', () => { + it('returns true if the cursor precedes the tail', () => { + selection.cursor.setScreenPosition([0, 20]) + selection.selectToScreenPosition([0, 10]) + expect(selection.isReversed()).toBeTruthy() + + selection.selectToScreenPosition([0, 25]) + expect(selection.isReversed()).toBeFalsy() + }) + }) + + describe('.selectLine(row)', () => { + describe('when passed a row', () => { + it('selects the specified row', () => { + selection.setBufferRange([[2, 4], [3, 4]]) + selection.selectLine(5) + expect(selection.getBufferRange()).toEqual([[5, 0], [6, 0]]) + }) + }) + + describe('when not passed a row', () => { + it('selects all rows spanned by the selection', () => { + selection.setBufferRange([[2, 4], [3, 4]]) + selection.selectLine() + expect(selection.getBufferRange()).toEqual([[2, 0], [4, 0]]) + }) + }) + }) + + describe("when only the selection's tail is moved (regression)", () => { + it('notifies ::onDidChangeRange observers', () => { + selection.setBufferRange([[2, 0], [2, 10]], {reversed: true}) + const changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') + selection.onDidChangeRange(changeScreenRangeHandler) + + buffer.insert([2, 5], 'abc') + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the selection is destroyed', () => { + it('destroys its marker', () => { + selection.setBufferRange([[2, 0], [2, 10]]) + const { marker } = selection + selection.destroy() + expect(marker.isDestroyed()).toBeTruthy() + }) + }) + + describe('.insertText(text, options)', () => { + it('allows pasting white space only lines when autoIndent is enabled', () => { + selection.setBufferRange([[0, 0], [0, 0]]) + selection.insertText(' \n \n\n', {autoIndent: true}) + expect(buffer.lineForRow(0)).toBe(' ') + expect(buffer.lineForRow(1)).toBe(' ') + expect(buffer.lineForRow(2)).toBe('') + }) + + it('auto-indents if only a newline is inserted', () => { + selection.setBufferRange([[2, 0], [3, 0]]) + selection.insertText('\n', {autoIndent: true}) + expect(buffer.lineForRow(2)).toBe(' ') + }) + + it('auto-indents if only a carriage return + newline is inserted', () => { + selection.setBufferRange([[2, 0], [3, 0]]) + selection.insertText('\r\n', {autoIndent: true}) + expect(buffer.lineForRow(2)).toBe(' ') + }) + + it('does not adjust the indent of trailing lines if preserveTrailingLineIndentation is true', () => { + selection.setBufferRange([[5, 0], [5, 0]]) + selection.insertText(' foo\n bar\n', {preserveTrailingLineIndentation: true, indentBasis: 1}) + expect(buffer.lineForRow(6)).toBe(' bar') + }) + }) + + describe('.fold()', () => { + it('folds the buffer range spanned by the selection', () => { + selection.setBufferRange([[0, 3], [1, 6]]) + selection.fold() + + expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]]) + expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]]) + expect(editor.lineTextForScreenRow(0)).toBe(`var${editor.displayLayer.foldCharacter}sort = function(items) {`) + expect(editor.isFoldedAtBufferRow(0)).toBe(true) + }) + + it("doesn't create a fold when the selection is empty", () => { + selection.setBufferRange([[0, 3], [0, 3]]) + selection.fold() + + expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]]) + expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]]) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + expect(editor.isFoldedAtBufferRow(0)).toBe(false) + }) + }) +}) From 3b6f98b446b7ac581f555542677afd986523d1f9 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 2 Nov 2017 09:29:33 -0700 Subject: [PATCH 3/3] Fix lint errors --- src/selection.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/selection.js b/src/selection.js index 20561fd64..a54ba68b8 100644 --- a/src/selection.js +++ b/src/selection.js @@ -613,7 +613,9 @@ class Selection { // Remove trailing whitespace from the current line const scanRange = this.cursor.getCurrentLineBufferRange() let trailingWhitespaceRange = null - this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({range}) => trailingWhitespaceRange = range) + this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({range}) => { + trailingWhitespaceRange = range + }) if (trailingWhitespaceRange) { this.setBufferRange(trailingWhitespaceRange) this.deleteSelectedText() @@ -886,7 +888,7 @@ class Selection { */ setGoalScreenRange (range) { - return this.goalScreenRange = Range.fromObject(range) + this.goalScreenRange = Range.fromObject(range) } getGoalScreenRange () {