Merge remote-tracking branch 'origin/master' into mkt-core-uri-handlers

This commit is contained in:
Michelle Tilley
2017-11-02 16:30:29 -07:00
53 changed files with 15685 additions and 13574 deletions

View File

@@ -43,7 +43,6 @@ PaneContainer = require './pane-container'
PaneAxis = require './pane-axis'
Pane = require './pane'
Dock = require './dock'
Project = require './project'
TextEditor = require './text-editor'
TextBuffer = require 'text-buffer'
Gutter = require './gutter'

View File

@@ -89,7 +89,7 @@ module.exports = class CommandRegistry {
// DOM element, the command will be associated with just that element.
// * `commandName` A {String} containing the name of a command you want to
// handle such as `user:insert-date`.
// * `listener` A listener which handles the event. Either A {Function} to
// * `listener` A listener which handles the event. Either a {Function} to
// call when the given command is invoked on an element matching the
// selector, or an {Object} with a `didDispatch` property which is such a
// function.
@@ -97,7 +97,7 @@ module.exports = class CommandRegistry {
// The function (`listener` itself if it is a function, or the `didDispatch`
// method if `listener` is an object) will be called with `this` referencing
// the matching DOM node and the following argument:
// * `event` A standard DOM event instance. Call `stopPropagation` or
// * `event`: A standard DOM event instance. Call `stopPropagation` or
// `stopImmediatePropagation` to terminate bubbling early.
//
// Additionally, `listener` may have additional properties which are returned
@@ -107,6 +107,13 @@ module.exports = class CommandRegistry {
// otherwise be generated from the event name.
// * `description`: Used by consumers to display detailed information about
// the command.
// * `hiddenInCommandPalette`: If `true`, this command will not appear in
// the bundled command palette by default, but can still be shown with.
// the `Command Palette: Show Hidden Commands` command. This is a good
// option when you need to register large numbers of commands that don't
// make sense to be executed from the command palette. Please use this
// option conservatively, as it could reduce the discoverability of your
// package's commands.
//
// ## Arguments: Registering Multiple Commands
//

View File

@@ -594,7 +594,7 @@ class Cursor extends Model {
getCurrentWordBufferRange (options = {}) {
const position = this.getBufferPosition()
const ranges = this.editor.buffer.findAllInRangeSync(
options.wordRegex || this.wordRegExp(),
options.wordRegex || this.wordRegExp(options),
new Range(new Point(position.row, 0), new Point(position.row, Infinity))
)
const range = ranges.find(range =>

View File

@@ -58,10 +58,10 @@ class GrammarRegistry extends FirstMate.GrammarRegistry {
let score = this.getGrammarPathScore(grammar, filePath)
if ((score > 0) && !grammar.bundledPackage) {
score += 0.25
score += 0.125
}
if (this.grammarMatchesContents(grammar, contents)) {
score += 0.125
score += 0.25
}
return score
}

View File

@@ -4,7 +4,7 @@ const fs = require('fs')
const path = require('path')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const nsfw = require('nsfw')
const nsfw = require('@atom/nsfw')
const {NativeWatcherRegistry} = require('./native-watcher-registry')
// Private: Associate native watcher action flags with descriptive String equivalents.

View File

@@ -174,6 +174,11 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
'core:cut': -> @cutSelectedText()
'core:copy': -> @copySelectedText()
'core:paste': -> @pasteText()
'editor:paste-without-reformatting': -> @pasteText({
normalizeLineEndings: false,
autoIndent: false,
preserveTrailingLineIndentation: true
})
'editor:delete-to-previous-word-boundary': -> @deleteToPreviousWordBoundary()
'editor:delete-to-next-word-boundary': -> @deleteToNextWordBoundary()
'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord()

View File

@@ -1,834 +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.destroyFoldsIntersectingBufferRange(bufferRange) 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).
# * `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?
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()

977
src/selection.js Normal file
View File

@@ -0,0 +1,977 @@
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) {
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()
}
}

View File

@@ -126,7 +126,6 @@ class TextEditorComponent {
this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this))
this.lineComponentsByScreenLineId = new Map()
this.overlayComponents = new Set()
this.overlayDimensionsByElement = new WeakMap()
this.shouldRenderDummyScrollbars = true
this.remeasureScrollbars = false
this.pendingAutoscroll = null
@@ -803,8 +802,10 @@ class TextEditorComponent {
{
key: overlayProps.element,
overlayComponents: this.overlayComponents,
measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element),
didResize: () => { this.updateSync() }
didResize: (overlayComponent) => {
this.updateOverlayToRender(overlayProps)
overlayComponent.update(overlayProps)
}
},
overlayProps
))
@@ -1339,42 +1340,46 @@ class TextEditorComponent {
})
}
updateOverlayToRender (decoration) {
const windowInnerHeight = this.getWindowInnerHeight()
const windowInnerWidth = this.getWindowInnerWidth()
const contentClientRect = this.refs.content.getBoundingClientRect()
const {element, screenPosition, avoidOverflow} = decoration
const {row, column} = screenPosition
let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight()
let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column)
const clientRect = element.getBoundingClientRect()
if (avoidOverflow !== false) {
const computedStyle = window.getComputedStyle(element)
const elementTop = wrapperTop + parseInt(computedStyle.marginTop)
const elementBottom = elementTop + clientRect.height
const flippedElementTop = wrapperTop - this.getLineHeight() - clientRect.height - parseInt(computedStyle.marginBottom)
const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft)
const elementRight = elementLeft + clientRect.width
if (elementBottom > windowInnerHeight && flippedElementTop >= 0) {
wrapperTop -= (elementTop - flippedElementTop)
}
if (elementLeft < 0) {
wrapperLeft -= elementLeft
} else if (elementRight > windowInnerWidth) {
wrapperLeft -= (elementRight - windowInnerWidth)
}
}
decoration.pixelTop = Math.round(wrapperTop)
decoration.pixelLeft = Math.round(wrapperLeft)
}
updateOverlaysToRender () {
const overlayCount = this.decorationsToRender.overlays.length
if (overlayCount === 0) return null
const windowInnerHeight = this.getWindowInnerHeight()
const windowInnerWidth = this.getWindowInnerWidth()
const contentClientRect = this.refs.content.getBoundingClientRect()
for (let i = 0; i < overlayCount; i++) {
const decoration = this.decorationsToRender.overlays[i]
const {element, screenPosition, avoidOverflow} = decoration
const {row, column} = screenPosition
let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight()
let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column)
const clientRect = element.getBoundingClientRect()
this.overlayDimensionsByElement.set(element, clientRect)
if (avoidOverflow !== false) {
const computedStyle = window.getComputedStyle(element)
const elementTop = wrapperTop + parseInt(computedStyle.marginTop)
const elementBottom = elementTop + clientRect.height
const flippedElementTop = wrapperTop - this.getLineHeight() - clientRect.height - parseInt(computedStyle.marginBottom)
const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft)
const elementRight = elementLeft + clientRect.width
if (elementBottom > windowInnerHeight && flippedElementTop >= 0) {
wrapperTop -= (elementTop - flippedElementTop)
}
if (elementLeft < 0) {
wrapperLeft -= elementLeft
} else if (elementRight > windowInnerWidth) {
wrapperLeft -= (elementRight - windowInnerWidth)
}
}
decoration.pixelTop = Math.round(wrapperTop)
decoration.pixelLeft = Math.round(wrapperLeft)
this.updateOverlayToRender(decoration)
}
}
@@ -1603,11 +1608,23 @@ class TextEditorComponent {
if (this.isInputEnabled()) {
event.stopPropagation()
// WARNING: If we call preventDefault on the input of a space character,
// then the browser interprets the spacebar keypress as a page-down command,
// causing spaces to scroll elements containing editors. This is impossible
// to test.
if (event.data !== ' ') event.preventDefault()
// WARNING: If we call preventDefault on the input of a space
// character, then the browser interprets the spacebar keypress as a
// page-down command, causing spaces to scroll elements containing
// editors. This means typing space will actually change the contents
// of the hidden input, which will cause the browser to autoscroll the
// scroll container to reveal the input if it is off screen (See
// https://github.com/atom/atom/issues/16046). To correct for this
// situation, we automatically reset the scroll position to 0,0 after
// typing a space. None of this can really be tested.
if (event.data === ' ') {
window.setImmediate(() => {
this.refs.scrollContainer.scrollTop = 0
this.refs.scrollContainer.scrollLeft = 0
})
} else {
event.preventDefault()
}
// If the input event is fired while the accented character menu is open it
// means that the user has chosen one of the accented alternatives. Thus, we
@@ -1640,8 +1657,11 @@ class TextEditorComponent {
didKeydown (event) {
// Stop dragging when user interacts with the keyboard. This prevents
// unwanted selections in the case edits are performed while selecting text
// at the same time.
if (this.stopDragging) this.stopDragging()
// at the same time. Modifier keys are exempt to preserve the ability to
// add selections, shift-scroll horizontally while selecting.
if (this.stopDragging && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta' && event.key !== 'Shift') {
this.stopDragging()
}
if (this.lastKeydownBeforeKeypress != null) {
if (this.lastKeydownBeforeKeypress.code === event.code) {
@@ -1758,7 +1778,7 @@ class TextEditorComponent {
if (target && target.matches('.fold-marker')) {
const bufferPosition = model.bufferPositionForScreenPosition(screenPosition)
model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition))
model.destroyFoldsContainingBufferPositions([bufferPosition], false)
return
}
@@ -2443,8 +2463,12 @@ class TextEditorComponent {
didChangeDisplayLayer (changes) {
for (let i = 0; i < changes.length; i++) {
const {start, oldExtent, newExtent} = changes[i]
this.spliceLineTopIndex(start.row, oldExtent.row, newExtent.row)
const {oldRange, newRange} = changes[i]
this.spliceLineTopIndex(
newRange.start.row,
oldRange.end.row - oldRange.start.row,
newRange.end.row - newRange.start.row
)
}
this.scheduleUpdate()
@@ -4194,17 +4218,26 @@ class OverlayComponent {
this.element.style.zIndex = 4
this.element.style.top = (this.props.pixelTop || 0) + 'px'
this.element.style.left = (this.props.pixelLeft || 0) + 'px'
this.currentContentRect = null
// Synchronous DOM updates in response to resize events might trigger a
// "loop limit exceeded" error. We disconnect the observer before
// potentially mutating the DOM, and then reconnect it on the next tick.
// Note: ResizeObserver calls its callback when .observe is called
this.resizeObserver = new ResizeObserver((entries) => {
const {contentRect} = entries[0]
if (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) {
if (
this.currentContentRect &&
(this.currentContentRect.width !== contentRect.width ||
this.currentContentRect.height !== contentRect.height)
) {
this.resizeObserver.disconnect()
this.props.didResize()
this.props.didResize(this)
process.nextTick(() => { this.resizeObserver.observe(this.props.element) })
}
this.currentContentRect = contentRect
})
this.didAttach()
this.props.overlayComponents.add(this)
@@ -4215,15 +4248,30 @@ class OverlayComponent {
this.didDetach()
}
getNextUpdatePromise () {
if (!this.nextUpdatePromise) {
this.nextUpdatePromise = new Promise((resolve) => {
this.resolveNextUpdatePromise = () => {
this.nextUpdatePromise = null
this.resolveNextUpdatePromise = null
resolve()
}
})
}
return this.nextUpdatePromise
}
update (newProps) {
const oldProps = this.props
this.props = newProps
this.props = Object.assign({}, oldProps, newProps)
if (this.props.pixelTop != null) this.element.style.top = this.props.pixelTop + 'px'
if (this.props.pixelLeft != null) this.element.style.left = this.props.pixelLeft + 'px'
if (newProps.className !== oldProps.className) {
if (oldProps.className != null) this.element.classList.remove(oldProps.className)
if (newProps.className != null) this.element.classList.add(newProps.className)
}
if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise()
}
didAttach () {

View File

@@ -288,7 +288,7 @@ export default class TextEditorRegistry {
let currentScore = this.editorGrammarScores.get(editor)
if (currentScore == null || score > currentScore) {
editor.setGrammar(grammar, score)
editor.setGrammar(grammar)
this.editorGrammarScores.set(editor, score)
}
}

File diff suppressed because it is too large Load Diff

4587
src/text-editor.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,322 +0,0 @@
path = require 'path'
_ = require 'underscore-plus'
{Emitter, CompositeDisposable} = require 'event-kit'
{File} = require 'pathwatcher'
fs = require 'fs-plus'
LessCompileCache = require './less-compile-cache'
# Extended: Handles loading and activating available themes.
#
# An instance of this class is always available as the `atom.themes` global.
module.exports =
class ThemeManager
constructor: ({@packageManager, @config, @styleManager, @notificationManager, @viewRegistry}) ->
@emitter = new Emitter
@styleSheetDisposablesBySourcePath = {}
@lessCache = null
@initialLoadComplete = false
@packageManager.registerPackageActivator(this, ['theme'])
@packageManager.onDidActivateInitialPackages =>
@onDidChangeActiveThemes => @packageManager.reloadActivePackageStyleSheets()
initialize: ({@resourcePath, @configDirPath, @safeMode, devMode}) ->
@lessSourcesByRelativeFilePath = null
if devMode or typeof snapshotAuxiliaryData is 'undefined'
@lessSourcesByRelativeFilePath = {}
@importedFilePathsByRelativeImportPath = {}
else
@lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath
@importedFilePathsByRelativeImportPath = snapshotAuxiliaryData.importedFilePathsByRelativeImportPath
###
Section: Event Subscription
###
# Essential: Invoke `callback` when style sheet changes associated with
# updating the list of active themes have completed.
#
# * `callback` {Function}
onDidChangeActiveThemes: (callback) ->
@emitter.on 'did-change-active-themes', callback
###
Section: Accessing Available Themes
###
getAvailableNames: ->
# TODO: Maybe should change to list all the available themes out there?
@getLoadedNames()
###
Section: Accessing Loaded Themes
###
# Public: Returns an {Array} of {String}s of all the loaded theme names.
getLoadedThemeNames: ->
theme.name for theme in @getLoadedThemes()
# Public: Returns an {Array} of all the loaded themes.
getLoadedThemes: ->
pack for pack in @packageManager.getLoadedPackages() when pack.isTheme()
###
Section: Accessing Active Themes
###
# Public: Returns an {Array} of {String}s all the active theme names.
getActiveThemeNames: ->
theme.name for theme in @getActiveThemes()
# Public: Returns an {Array} of all the active themes.
getActiveThemes: ->
pack for pack in @packageManager.getActivePackages() when pack.isTheme()
activatePackages: -> @activateThemes()
###
Section: Managing Enabled Themes
###
warnForNonExistentThemes: ->
themeNames = @config.get('core.themes') ? []
themeNames = [themeNames] unless _.isArray(themeNames)
for themeName in themeNames
unless themeName and typeof themeName is 'string' and @packageManager.resolvePackagePath(themeName)
console.warn("Enabled theme '#{themeName}' is not installed.")
# Public: Get the enabled theme names from the config.
#
# Returns an array of theme names in the order that they should be activated.
getEnabledThemeNames: ->
themeNames = @config.get('core.themes') ? []
themeNames = [themeNames] unless _.isArray(themeNames)
themeNames = themeNames.filter (themeName) =>
if themeName and typeof themeName is 'string'
return true if @packageManager.resolvePackagePath(themeName)
false
# Use a built-in syntax and UI theme any time the configured themes are not
# available.
if themeNames.length < 2
builtInThemeNames = [
'atom-dark-syntax'
'atom-dark-ui'
'atom-light-syntax'
'atom-light-ui'
'base16-tomorrow-dark-theme'
'base16-tomorrow-light-theme'
'solarized-dark-syntax'
'solarized-light-syntax'
]
themeNames = _.intersection(themeNames, builtInThemeNames)
if themeNames.length is 0
themeNames = ['atom-dark-syntax', 'atom-dark-ui']
else if themeNames.length is 1
if _.endsWith(themeNames[0], '-ui')
themeNames.unshift('atom-dark-syntax')
else
themeNames.push('atom-dark-ui')
# Reverse so the first (top) theme is loaded after the others. We want
# the first/top theme to override later themes in the stack.
themeNames.reverse()
###
Section: Private
###
# Resolve and apply the stylesheet specified by the path.
#
# This supports both CSS and Less stylesheets.
#
# * `stylesheetPath` A {String} path to the stylesheet that can be an absolute
# path or a relative path that will be resolved against the load path.
#
# Returns a {Disposable} on which `.dispose()` can be called to remove the
# required stylesheet.
requireStylesheet: (stylesheetPath, priority, skipDeprecatedSelectorsTransformation) ->
if fullPath = @resolveStylesheet(stylesheetPath)
content = @loadStylesheet(fullPath)
@applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation)
else
throw new Error("Could not find a file at path '#{stylesheetPath}'")
unwatchUserStylesheet: ->
@userStylesheetSubscriptions?.dispose()
@userStylesheetSubscriptions = null
@userStylesheetFile = null
@userStyleSheetDisposable?.dispose()
@userStyleSheetDisposable = null
loadUserStylesheet: ->
@unwatchUserStylesheet()
userStylesheetPath = @styleManager.getUserStyleSheetPath()
return unless fs.isFileSync(userStylesheetPath)
try
@userStylesheetFile = new File(userStylesheetPath)
@userStylesheetSubscriptions = new CompositeDisposable()
reloadStylesheet = => @loadUserStylesheet()
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet))
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet))
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet))
catch error
message = """
Unable to watch path: `#{path.basename(userStylesheetPath)}`. Make sure
you have permissions to `#{userStylesheetPath}`.
On linux there are currently problems with watch sizes. See
[this document][watches] for more info.
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path
"""
@notificationManager.addError(message, dismissable: true)
try
userStylesheetContents = @loadStylesheet(userStylesheetPath, true)
catch
return
@userStyleSheetDisposable = @styleManager.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2)
loadBaseStylesheets: ->
@reloadBaseStylesheets()
reloadBaseStylesheets: ->
@requireStylesheet('../static/atom', -2, true)
stylesheetElementForId: (id) ->
escapedId = id.replace(/\\/g, '\\\\')
document.head.querySelector("atom-styles style[source-path=\"#{escapedId}\"]")
resolveStylesheet: (stylesheetPath) ->
if path.extname(stylesheetPath).length > 0
fs.resolveOnLoadPath(stylesheetPath)
else
fs.resolveOnLoadPath(stylesheetPath, ['css', 'less'])
loadStylesheet: (stylesheetPath, importFallbackVariables) ->
if path.extname(stylesheetPath) is '.less'
@loadLessStylesheet(stylesheetPath, importFallbackVariables)
else
fs.readFileSync(stylesheetPath, 'utf8')
loadLessStylesheet: (lessStylesheetPath, importFallbackVariables=false) ->
@lessCache ?= new LessCompileCache({
@resourcePath,
@lessSourcesByRelativeFilePath,
@importedFilePathsByRelativeImportPath,
importPaths: @getImportPaths()
})
try
if importFallbackVariables
baseVarImports = """
@import "variables/ui-variables";
@import "variables/syntax-variables";
"""
relativeFilePath = path.relative(@resourcePath, lessStylesheetPath)
lessSource = @lessSourcesByRelativeFilePath[relativeFilePath]
if lessSource?
content = lessSource.content
digest = lessSource.digest
else
content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8')
digest = null
@lessCache.cssForFile(lessStylesheetPath, content, digest)
else
@lessCache.read(lessStylesheetPath)
catch error
error.less = true
if error.line?
# Adjust line numbers for import fallbacks
error.line -= 2 if importFallbackVariables
message = "Error compiling Less stylesheet: `#{lessStylesheetPath}`"
detail = """
Line number: #{error.line}
#{error.message}
"""
else
message = "Error loading Less stylesheet: `#{lessStylesheetPath}`"
detail = error.message
@notificationManager.addError(message, {detail, dismissable: true})
throw error
removeStylesheet: (stylesheetPath) ->
@styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose()
applyStylesheet: (path, text, priority, skipDeprecatedSelectorsTransformation) ->
@styleSheetDisposablesBySourcePath[path] = @styleManager.addStyleSheet(
text,
{
priority,
skipDeprecatedSelectorsTransformation,
sourcePath: path
}
)
activateThemes: ->
new Promise (resolve) =>
# @config.observe runs the callback once, then on subsequent changes.
@config.observe 'core.themes', =>
@deactivateThemes().then =>
@warnForNonExistentThemes()
@refreshLessCache() # Update cache for packages in core.themes config
promises = []
for themeName in @getEnabledThemeNames()
if @packageManager.resolvePackagePath(themeName)
promises.push(@packageManager.activatePackage(themeName))
else
console.warn("Failed to activate theme '#{themeName}' because it isn't installed.")
Promise.all(promises).then =>
@addActiveThemeClasses()
@refreshLessCache() # Update cache again now that @getActiveThemes() is populated
@loadUserStylesheet()
@reloadBaseStylesheets()
@initialLoadComplete = true
@emitter.emit 'did-change-active-themes'
resolve()
deactivateThemes: ->
@removeActiveThemeClasses()
@unwatchUserStylesheet()
results = @getActiveThemes().map((pack) => @packageManager.deactivatePackage(pack.name))
Promise.all(results.filter((r) -> typeof r?.then is 'function'))
isInitialLoadComplete: -> @initialLoadComplete
addActiveThemeClasses: ->
if workspaceElement = @viewRegistry.getView(@workspace)
for pack in @getActiveThemes()
workspaceElement.classList.add("theme-#{pack.name}")
return
removeActiveThemeClasses: ->
workspaceElement = @viewRegistry.getView(@workspace)
for pack in @getActiveThemes()
workspaceElement.classList.remove("theme-#{pack.name}")
return
refreshLessCache: ->
@lessCache?.setImportPaths(@getImportPaths())
getImportPaths: ->
activeThemes = @getActiveThemes()
if activeThemes.length > 0
themePaths = (theme.getStylesheetsPath() for theme in activeThemes when theme)
else
themePaths = []
for themeName in @getEnabledThemeNames()
if themePath = @packageManager.resolvePackagePath(themeName)
deprecatedPath = path.join(themePath, 'stylesheets')
if fs.isDirectorySync(deprecatedPath)
themePaths.push(deprecatedPath)
else
themePaths.push(path.join(themePath, 'styles'))
themePaths.filter (themePath) -> fs.isDirectorySync(themePath)

401
src/theme-manager.js Normal file
View File

@@ -0,0 +1,401 @@
/* global snapshotAuxiliaryData */
const path = require('path')
const _ = require('underscore-plus')
const {Emitter, CompositeDisposable} = require('event-kit')
const {File} = require('pathwatcher')
const fs = require('fs-plus')
const LessCompileCache = require('./less-compile-cache')
// Extended: Handles loading and activating available themes.
//
// An instance of this class is always available as the `atom.themes` global.
module.exports =
class ThemeManager {
constructor ({packageManager, config, styleManager, notificationManager, viewRegistry}) {
this.packageManager = packageManager
this.config = config
this.styleManager = styleManager
this.notificationManager = notificationManager
this.viewRegistry = viewRegistry
this.emitter = new Emitter()
this.styleSheetDisposablesBySourcePath = {}
this.lessCache = null
this.initialLoadComplete = false
this.packageManager.registerPackageActivator(this, ['theme'])
this.packageManager.onDidActivateInitialPackages(() => {
this.onDidChangeActiveThemes(() => this.packageManager.reloadActivePackageStyleSheets())
})
}
initialize ({resourcePath, configDirPath, safeMode, devMode}) {
this.resourcePath = resourcePath
this.configDirPath = configDirPath
this.safeMode = safeMode
this.lessSourcesByRelativeFilePath = null
if (devMode || (typeof snapshotAuxiliaryData === 'undefined')) {
this.lessSourcesByRelativeFilePath = {}
this.importedFilePathsByRelativeImportPath = {}
} else {
this.lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath
this.importedFilePathsByRelativeImportPath = snapshotAuxiliaryData.importedFilePathsByRelativeImportPath
}
}
/*
Section: Event Subscription
*/
// Essential: Invoke `callback` when style sheet changes associated with
// updating the list of active themes have completed.
//
// * `callback` {Function}
onDidChangeActiveThemes (callback) {
return this.emitter.on('did-change-active-themes', callback)
}
/*
Section: Accessing Available Themes
*/
getAvailableNames () {
// TODO: Maybe should change to list all the available themes out there?
return this.getLoadedNames()
}
/*
Section: Accessing Loaded Themes
*/
// Public: Returns an {Array} of {String}s of all the loaded theme names.
getLoadedThemeNames () {
return this.getLoadedThemes().map((theme) => theme.name)
}
// Public: Returns an {Array} of all the loaded themes.
getLoadedThemes () {
return this.packageManager.getLoadedPackages().filter((pack) => pack.isTheme())
}
/*
Section: Accessing Active Themes
*/
// Public: Returns an {Array} of {String}s of all the active theme names.
getActiveThemeNames () {
return this.getActiveThemes().map((theme) => theme.name)
}
// Public: Returns an {Array} of all the active themes.
getActiveThemes () {
return this.packageManager.getActivePackages().filter((pack) => pack.isTheme())
}
activatePackages () {
return this.activateThemes()
}
/*
Section: Managing Enabled Themes
*/
warnForNonExistentThemes () {
let themeNames = this.config.get('core.themes') || []
if (!_.isArray(themeNames)) { themeNames = [themeNames] }
for (let themeName of themeNames) {
if (!themeName || (typeof themeName !== 'string') || !this.packageManager.resolvePackagePath(themeName)) {
console.warn(`Enabled theme '${themeName}' is not installed.`)
}
}
}
// Public: Get the enabled theme names from the config.
//
// Returns an array of theme names in the order that they should be activated.
getEnabledThemeNames () {
let themeNames = this.config.get('core.themes') || []
if (!_.isArray(themeNames)) { themeNames = [themeNames] }
themeNames = themeNames.filter((themeName) =>
(typeof themeName === 'string') && this.packageManager.resolvePackagePath(themeName)
)
// Use a built-in syntax and UI theme any time the configured themes are not
// available.
if (themeNames.length < 2) {
const builtInThemeNames = [
'atom-dark-syntax',
'atom-dark-ui',
'atom-light-syntax',
'atom-light-ui',
'base16-tomorrow-dark-theme',
'base16-tomorrow-light-theme',
'solarized-dark-syntax',
'solarized-light-syntax'
]
themeNames = _.intersection(themeNames, builtInThemeNames)
if (themeNames.length === 0) {
themeNames = ['atom-dark-syntax', 'atom-dark-ui']
} else if (themeNames.length === 1) {
if (_.endsWith(themeNames[0], '-ui')) {
themeNames.unshift('atom-dark-syntax')
} else {
themeNames.push('atom-dark-ui')
}
}
}
// Reverse so the first (top) theme is loaded after the others. We want
// the first/top theme to override later themes in the stack.
return themeNames.reverse()
}
/*
Section: Private
*/
// Resolve and apply the stylesheet specified by the path.
//
// This supports both CSS and Less stylesheets.
//
// * `stylesheetPath` A {String} path to the stylesheet that can be an absolute
// path or a relative path that will be resolved against the load path.
//
// Returns a {Disposable} on which `.dispose()` can be called to remove the
// required stylesheet.
requireStylesheet (stylesheetPath, priority, skipDeprecatedSelectorsTransformation) {
let fullPath = this.resolveStylesheet(stylesheetPath)
if (fullPath) {
const content = this.loadStylesheet(fullPath)
return this.applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation)
} else {
throw new Error(`Could not find a file at path '${stylesheetPath}'`)
}
}
unwatchUserStylesheet () {
if (this.userStylesheetSubscriptions != null) this.userStylesheetSubscriptions.dispose()
this.userStylesheetSubscriptions = null
this.userStylesheetFile = null
if (this.userStyleSheetDisposable != null) this.userStyleSheetDisposable.dispose()
this.userStyleSheetDisposable = null
}
loadUserStylesheet () {
this.unwatchUserStylesheet()
const userStylesheetPath = this.styleManager.getUserStyleSheetPath()
if (!fs.isFileSync(userStylesheetPath)) { return }
try {
this.userStylesheetFile = new File(userStylesheetPath)
this.userStylesheetSubscriptions = new CompositeDisposable()
const reloadStylesheet = () => this.loadUserStylesheet()
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidChange(reloadStylesheet))
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidRename(reloadStylesheet))
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidDelete(reloadStylesheet))
} catch (error) {
const message = `\
Unable to watch path: \`${path.basename(userStylesheetPath)}\`. Make sure
you have permissions to \`${userStylesheetPath}\`.
On linux there are currently problems with watch sizes. See
[this document][watches] for more info.
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\
`
this.notificationManager.addError(message, {dismissable: true})
}
let userStylesheetContents
try {
userStylesheetContents = this.loadStylesheet(userStylesheetPath, true)
} catch (error) {
return
}
this.userStyleSheetDisposable = this.styleManager.addStyleSheet(userStylesheetContents, {sourcePath: userStylesheetPath, priority: 2})
}
loadBaseStylesheets () {
this.reloadBaseStylesheets()
}
reloadBaseStylesheets () {
this.requireStylesheet('../static/atom', -2, true)
}
stylesheetElementForId (id) {
const escapedId = id.replace(/\\/g, '\\\\')
return document.head.querySelector(`atom-styles style[source-path="${escapedId}"]`)
}
resolveStylesheet (stylesheetPath) {
if (path.extname(stylesheetPath).length > 0) {
return fs.resolveOnLoadPath(stylesheetPath)
} else {
return fs.resolveOnLoadPath(stylesheetPath, ['css', 'less'])
}
}
loadStylesheet (stylesheetPath, importFallbackVariables) {
if (path.extname(stylesheetPath) === '.less') {
return this.loadLessStylesheet(stylesheetPath, importFallbackVariables)
} else {
return fs.readFileSync(stylesheetPath, 'utf8')
}
}
loadLessStylesheet (lessStylesheetPath, importFallbackVariables = false) {
if (this.lessCache == null) {
this.lessCache = new LessCompileCache({
resourcePath: this.resourcePath,
lessSourcesByRelativeFilePath: this.lessSourcesByRelativeFilePath,
importedFilePathsByRelativeImportPath: this.importedFilePathsByRelativeImportPath,
importPaths: this.getImportPaths()
})
}
try {
if (importFallbackVariables) {
const baseVarImports = `\
@import "variables/ui-variables";
@import "variables/syntax-variables";\
`
const relativeFilePath = path.relative(this.resourcePath, lessStylesheetPath)
const lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath]
let content, digest
if (lessSource != null) {
({ content } = lessSource);
({ digest } = lessSource)
} else {
content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8')
digest = null
}
return this.lessCache.cssForFile(lessStylesheetPath, content, digest)
} else {
return this.lessCache.read(lessStylesheetPath)
}
} catch (error) {
let detail, message
error.less = true
if (error.line != null) {
// Adjust line numbers for import fallbacks
if (importFallbackVariables) { error.line -= 2 }
message = `Error compiling Less stylesheet: \`${lessStylesheetPath}\``
detail = `Line number: ${error.line}\n${error.message}`
} else {
message = `Error loading Less stylesheet: \`${lessStylesheetPath}\``
detail = error.message
}
this.notificationManager.addError(message, {detail, dismissable: true})
throw error
}
}
removeStylesheet (stylesheetPath) {
if (this.styleSheetDisposablesBySourcePath[stylesheetPath] != null) {
this.styleSheetDisposablesBySourcePath[stylesheetPath].dispose()
}
}
applyStylesheet (path, text, priority, skipDeprecatedSelectorsTransformation) {
this.styleSheetDisposablesBySourcePath[path] = this.styleManager.addStyleSheet(
text,
{
priority,
skipDeprecatedSelectorsTransformation,
sourcePath: path
}
)
return this.styleSheetDisposablesBySourcePath[path]
}
activateThemes () {
return new Promise(resolve => {
// @config.observe runs the callback once, then on subsequent changes.
this.config.observe('core.themes', () => {
this.deactivateThemes().then(() => {
this.warnForNonExistentThemes()
this.refreshLessCache() // Update cache for packages in core.themes config
const promises = []
for (const themeName of this.getEnabledThemeNames()) {
if (this.packageManager.resolvePackagePath(themeName)) {
promises.push(this.packageManager.activatePackage(themeName))
} else {
console.warn(`Failed to activate theme '${themeName}' because it isn't installed.`)
}
}
return Promise.all(promises).then(() => {
this.addActiveThemeClasses()
this.refreshLessCache() // Update cache again now that @getActiveThemes() is populated
this.loadUserStylesheet()
this.reloadBaseStylesheets()
this.initialLoadComplete = true
this.emitter.emit('did-change-active-themes')
resolve()
})
})
})
})
}
deactivateThemes () {
this.removeActiveThemeClasses()
this.unwatchUserStylesheet()
const results = this.getActiveThemes().map(pack => this.packageManager.deactivatePackage(pack.name))
return Promise.all(results.filter((r) => (r != null) && (typeof r.then === 'function')))
}
isInitialLoadComplete () {
return this.initialLoadComplete
}
addActiveThemeClasses () {
const workspaceElement = this.viewRegistry.getView(this.workspace)
if (workspaceElement) {
for (const pack of this.getActiveThemes()) {
workspaceElement.classList.add(`theme-${pack.name}`)
}
}
}
removeActiveThemeClasses () {
const workspaceElement = this.viewRegistry.getView(this.workspace)
for (const pack of this.getActiveThemes()) {
workspaceElement.classList.remove(`theme-${pack.name}`)
}
}
refreshLessCache () {
if (this.lessCache) this.lessCache.setImportPaths(this.getImportPaths())
}
getImportPaths () {
let themePaths
const activeThemes = this.getActiveThemes()
if (activeThemes.length > 0) {
themePaths = (activeThemes.filter((theme) => theme).map((theme) => theme.getStylesheetsPath()))
} else {
themePaths = []
for (const themeName of this.getEnabledThemeNames()) {
const themePath = this.packageManager.resolvePackagePath(themeName)
if (themePath) {
const deprecatedPath = path.join(themePath, 'stylesheets')
if (fs.isDirectorySync(deprecatedPath)) {
themePaths.push(deprecatedPath)
} else {
themePaths.push(path.join(themePath, 'styles'))
}
}
}
}
return themePaths.filter(themePath => fs.isDirectorySync(themePath))
}
}

View File

@@ -1,37 +0,0 @@
path = require 'path'
Package = require './package'
module.exports =
class ThemePackage extends Package
getType: -> 'theme'
getStyleSheetPriority: -> 1
enable: ->
@config.unshiftAtKeyPath('core.themes', @name)
disable: ->
@config.removeAtKeyPath('core.themes', @name)
preload: ->
@loadTime = 0
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
finishLoading: ->
@path = path.join(@packageManager.resourcePath, @path)
load: ->
@loadTime = 0
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
this
activate: ->
@activationPromise ?= new Promise (resolve, reject) =>
@resolveActivationPromise = resolve
@rejectActivationPromise = reject
@measure 'activateTime', =>
try
@loadStylesheets()
@activateNow()
catch error
@handleError("Failed to activate the #{@name} theme", error)

55
src/theme-package.js Normal file
View File

@@ -0,0 +1,55 @@
const path = require('path')
const Package = require('./package')
module.exports =
class ThemePackage extends Package {
getType () {
return 'theme'
}
getStyleSheetPriority () {
return 1
}
enable () {
this.config.unshiftAtKeyPath('core.themes', this.name)
}
disable () {
this.config.removeAtKeyPath('core.themes', this.name)
}
preload () {
this.loadTime = 0
this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata()
}
finishLoading () {
this.path = path.join(this.packageManager.resourcePath, this.path)
}
load () {
this.loadTime = 0
this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata()
return this
}
activate () {
if (this.activationPromise == null) {
this.activationPromise = new Promise((resolve, reject) => {
this.resolveActivationPromise = resolve
this.rejectActivationPromise = reject
this.measure('activateTime', () => {
try {
this.loadStylesheets()
this.activateNow()
} catch (error) {
this.handleError(`Failed to activate the ${this.name} theme`, error)
}
})
})
}
return this.activationPromise
}
}

View File

@@ -163,99 +163,12 @@ class TokenizedBuffer {
Section - Comments
*/
toggleLineCommentsForBufferRows (start, end) {
const scope = this.scopeDescriptorForPosition([start, 0])
const commentStrings = this.commentStringsForScopeDescriptor(scope)
if (!commentStrings) return
const {commentStartString, commentEndString} = commentStrings
if (!commentStartString) return
const commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?')
const commentStartRegex = new OnigRegExp(`^(\\s*)(${commentStartRegexString})`)
if (commentEndString) {
const shouldUncomment = commentStartRegex.testSync(this.buffer.lineForRow(start))
if (shouldUncomment) {
const commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?')
const commentEndRegex = new OnigRegExp(`(${commentEndRegexString})(\\s*)$`)
const startMatch = commentStartRegex.searchSync(this.buffer.lineForRow(start))
const endMatch = commentEndRegex.searchSync(this.buffer.lineForRow(end))
if (startMatch && endMatch) {
this.buffer.transact(() => {
const columnStart = startMatch[1].length
const columnEnd = columnStart + startMatch[2].length
this.buffer.setTextInRange([[start, columnStart], [start, columnEnd]], '')
const endLength = this.buffer.lineLengthForRow(end) - endMatch[2].length
const endColumn = endLength - endMatch[1].length
return this.buffer.setTextInRange([[end, endColumn], [end, endLength]], '')
})
}
} else {
this.buffer.transact(() => {
const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0].length
this.buffer.insert([start, indentLength], commentStartString)
this.buffer.insert([end, this.buffer.lineLengthForRow(end)], commentEndString)
})
}
commentStringsForPosition (position) {
if (this.scopedSettingsDelegate) {
const scope = this.scopeDescriptorForPosition(position)
return this.scopedSettingsDelegate.getCommentStrings(scope)
} else {
let hasCommentedLines = false
let hasUncommentedLines = false
for (let row = start; row <= end; row++) {
const line = this.buffer.lineForRow(row)
if (NON_WHITESPACE_REGEX.test(line)) {
if (commentStartRegex.testSync(line)) {
hasCommentedLines = true
} else {
hasUncommentedLines = true
}
}
}
const shouldUncomment = hasCommentedLines && !hasUncommentedLines
if (shouldUncomment) {
for (let row = start; row <= end; row++) {
const match = commentStartRegex.searchSync(this.buffer.lineForRow(row))
if (match) {
const columnStart = match[1].length
const columnEnd = columnStart + match[2].length
this.buffer.setTextInRange([[row, columnStart], [row, columnEnd]], '')
}
}
} else {
let minIndentLevel = Infinity
let minBlankIndentLevel = Infinity
for (let row = start; row <= end; row++) {
const line = this.buffer.lineForRow(row)
const indentLevel = this.indentLevelForLine(line)
if (NON_WHITESPACE_REGEX.test(line)) {
if (indentLevel < minIndentLevel) minIndentLevel = indentLevel
} else {
if (indentLevel < minBlankIndentLevel) minBlankIndentLevel = indentLevel
}
}
minIndentLevel = Number.isFinite(minIndentLevel)
? minIndentLevel
: Number.isFinite(minBlankIndentLevel)
? minBlankIndentLevel
: 0
const tabLength = this.getTabLength()
const indentString = ' '.repeat(tabLength * minIndentLevel)
for (let row = start; row <= end; row++) {
const line = this.buffer.lineForRow(row)
if (NON_WHITESPACE_REGEX.test(line)) {
const indentColumn = this.columnForIndentLevel(line, minIndentLevel)
this.buffer.insert(Point(row, indentColumn), commentStartString)
} else {
this.buffer.setTextInRange(
new Range(new Point(row, 0), new Point(row, Infinity)),
indentString + commentStartString
)
}
}
}
return {}
}
}
@@ -594,24 +507,6 @@ class TokenizedBuffer {
return scopes
}
columnForIndentLevel (line, indentLevel, tabLength = this.tabLength) {
let column = 0
let indentLength = 0
const goalIndentLength = indentLevel * tabLength
while (indentLength < goalIndentLength) {
const char = line[column]
if (char === '\t') {
indentLength += tabLength - (indentLength % tabLength)
} else if (char === ' ') {
indentLength++
} else {
break
}
column++
}
return column
}
indentLevelForLine (line, tabLength = this.tabLength) {
let indentLength = 0
for (let i = 0, {length} = line; i < length; i++) {
@@ -841,12 +736,6 @@ class TokenizedBuffer {
}
}
commentStringsForScopeDescriptor (scopes) {
if (this.scopedSettingsDelegate) {
return this.scopedSettingsDelegate.getCommentStrings(scopes)
}
}
regexForPattern (pattern) {
if (pattern) {
if (!this.regexesByPattern[pattern]) {

View File

@@ -1,176 +0,0 @@
_ = require 'underscore-plus'
{Disposable, CompositeDisposable} = require 'event-kit'
Tooltip = null
# Essential: Associates tooltips with HTML elements.
#
# You can get the `TooltipManager` via `atom.tooltips`.
#
# ## Examples
#
# The essence of displaying a tooltip
#
# ```coffee
# # display it
# disposable = atom.tooltips.add(div, {title: 'This is a tooltip'})
#
# # remove it
# disposable.dispose()
# ```
#
# In practice there are usually multiple tooltips. So we add them to a
# CompositeDisposable
#
# ```coffee
# {CompositeDisposable} = require 'atom'
# subscriptions = new CompositeDisposable
#
# div1 = document.createElement('div')
# div2 = document.createElement('div')
# subscriptions.add atom.tooltips.add(div1, {title: 'This is a tooltip'})
# subscriptions.add atom.tooltips.add(div2, {title: 'Another tooltip'})
#
# # remove them all
# subscriptions.dispose()
# ```
#
# You can display a key binding in the tooltip as well with the
# `keyBindingCommand` option.
#
# ```coffee
# disposable = atom.tooltips.add @caseOptionButton,
# title: "Match Case"
# keyBindingCommand: 'find-and-replace:toggle-case-option'
# keyBindingTarget: @findEditor.element
# ```
module.exports =
class TooltipManager
defaults:
trigger: 'hover'
container: 'body'
html: true
placement: 'auto top'
viewportPadding: 2
hoverDefaults:
{delay: {show: 1000, hide: 100}}
constructor: ({@keymapManager, @viewRegistry}) ->
@tooltips = new Map()
# Essential: Add a tooltip to the given element.
#
# * `target` An `HTMLElement`
# * `options` An object with one or more of the following options:
# * `title` A {String} or {Function} to use for the text in the tip. If
# a function is passed, `this` will be set to the `target` element. This
# option is mutually exclusive with the `item` option.
# * `html` A {Boolean} affecting the interpretation of the `title` option.
# If `true` (the default), the `title` string will be interpreted as HTML.
# Otherwise it will be interpreted as plain text.
# * `item` A view (object with an `.element` property) or a DOM element
# containing custom content for the tooltip. This option is mutually
# exclusive with the `title` option.
# * `class` A {String} with a class to apply to the tooltip element to
# enable custom styling.
# * `placement` A {String} or {Function} returning a string to indicate
# the position of the tooltip relative to `element`. Can be `'top'`,
# `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is
# specified, it will dynamically reorient the tooltip. For example, if
# placement is `'auto left'`, the tooltip will display to the left when
# possible, otherwise it will display right.
# When a function is used to determine the placement, it is called with
# the tooltip DOM node as its first argument and the triggering element
# DOM node as its second. The `this` context is set to the tooltip
# instance.
# * `trigger` A {String} indicating how the tooltip should be displayed.
# Choose from one of the following options:
# * `'hover'` Show the tooltip when the mouse hovers over the element.
# This is the default.
# * `'click'` Show the tooltip when the element is clicked. The tooltip
# will be hidden after clicking the element again or anywhere else
# outside of the tooltip itself.
# * `'focus'` Show the tooltip when the element is focused.
# * `'manual'` Show the tooltip immediately and only hide it when the
# returned disposable is disposed.
# * `delay` An object specifying the show and hide delay in milliseconds.
# Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and
# otherwise defaults to `0` for both values.
# * `keyBindingCommand` A {String} containing a command name. If you specify
# this option and a key binding exists that matches the command, it will
# be appended to the title or rendered alone if no title is specified.
# * `keyBindingTarget` An `HTMLElement` on which to look up the key binding.
# If this option is not supplied, the first of all matching key bindings
# for the given command will be rendered.
#
# Returns a {Disposable} on which `.dispose()` can be called to remove the
# tooltip.
add: (target, options) ->
if target.jquery
disposable = new CompositeDisposable
disposable.add @add(element, options) for element in target
return disposable
Tooltip ?= require './tooltip'
{keyBindingCommand, keyBindingTarget} = options
if keyBindingCommand?
bindings = @keymapManager.findKeyBindings(command: keyBindingCommand, target: keyBindingTarget)
keystroke = getKeystroke(bindings)
if options.title? and keystroke?
options.title += " " + getKeystroke(bindings)
else if keystroke?
options.title = getKeystroke(bindings)
delete options.selector
options = _.defaults(options, @defaults)
if options.trigger is 'hover'
options = _.defaults(options, @hoverDefaults)
tooltip = new Tooltip(target, options, @viewRegistry)
if not @tooltips.has(target)
@tooltips.set(target, [])
@tooltips.get(target).push(tooltip)
hideTooltip = ->
tooltip.leave(currentTarget: target)
tooltip.hide()
window.addEventListener('resize', hideTooltip)
disposable = new Disposable =>
window.removeEventListener('resize', hideTooltip)
hideTooltip()
tooltip.destroy()
if @tooltips.has(target)
tooltipsForTarget = @tooltips.get(target)
index = tooltipsForTarget.indexOf(tooltip)
if index isnt -1
tooltipsForTarget.splice(index, 1)
if tooltipsForTarget.length is 0
@tooltips.delete(target)
disposable
# Extended: Find the tooltips that have been applied to the given element.
#
# * `target` The `HTMLElement` to find tooltips on.
#
# Returns an {Array} of `Tooltip` objects that match the `target`.
findTooltips: (target) ->
if @tooltips.has(target)
@tooltips.get(target).slice()
else
[]
humanizeKeystrokes = (keystroke) ->
keystrokes = keystroke.split(' ')
keystrokes = (_.humanizeKeystroke(stroke) for stroke in keystrokes)
keystrokes.join(' ')
getKeystroke = (bindings) ->
if bindings?.length
"<span class=\"keystroke\">#{humanizeKeystrokes(bindings[0].keystrokes)}</span>"

199
src/tooltip-manager.js Normal file
View File

@@ -0,0 +1,199 @@
const _ = require('underscore-plus')
const {Disposable, CompositeDisposable} = require('event-kit')
let Tooltip = null
// Essential: Associates tooltips with HTML elements.
//
// You can get the `TooltipManager` via `atom.tooltips`.
//
// ## Examples
//
// The essence of displaying a tooltip
//
// ```javascript
// // display it
// const disposable = atom.tooltips.add(div, {title: 'This is a tooltip'})
//
// // remove it
// disposable.dispose()
// ```
//
// In practice there are usually multiple tooltips. So we add them to a
// CompositeDisposable
//
// ```javascript
// const {CompositeDisposable} = require('atom')
// const subscriptions = new CompositeDisposable()
//
// const div1 = document.createElement('div')
// const div2 = document.createElement('div')
// subscriptions.add(atom.tooltips.add(div1, {title: 'This is a tooltip'}))
// subscriptions.add(atom.tooltips.add(div2, {title: 'Another tooltip'}))
//
// // remove them all
// subscriptions.dispose()
// ```
//
// You can display a key binding in the tooltip as well with the
// `keyBindingCommand` option.
//
// ```javascript
// disposable = atom.tooltips.add(this.caseOptionButton, {
// title: 'Match Case',
// keyBindingCommand: 'find-and-replace:toggle-case-option',
// keyBindingTarget: this.findEditor.element
// })
// ```
module.exports =
class TooltipManager {
constructor ({keymapManager, viewRegistry}) {
this.defaults = {
trigger: 'hover',
container: 'body',
html: true,
placement: 'auto top',
viewportPadding: 2
}
this.hoverDefaults = {
delay: {show: 1000, hide: 100}
}
this.keymapManager = keymapManager
this.viewRegistry = viewRegistry
this.tooltips = new Map()
}
// Essential: Add a tooltip to the given element.
//
// * `target` An `HTMLElement`
// * `options` An object with one or more of the following options:
// * `title` A {String} or {Function} to use for the text in the tip. If
// a function is passed, `this` will be set to the `target` element. This
// option is mutually exclusive with the `item` option.
// * `html` A {Boolean} affecting the interpretation of the `title` option.
// If `true` (the default), the `title` string will be interpreted as HTML.
// Otherwise it will be interpreted as plain text.
// * `item` A view (object with an `.element` property) or a DOM element
// containing custom content for the tooltip. This option is mutually
// exclusive with the `title` option.
// * `class` A {String} with a class to apply to the tooltip element to
// enable custom styling.
// * `placement` A {String} or {Function} returning a string to indicate
// the position of the tooltip relative to `element`. Can be `'top'`,
// `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is
// specified, it will dynamically reorient the tooltip. For example, if
// placement is `'auto left'`, the tooltip will display to the left when
// possible, otherwise it will display right.
// When a function is used to determine the placement, it is called with
// the tooltip DOM node as its first argument and the triggering element
// DOM node as its second. The `this` context is set to the tooltip
// instance.
// * `trigger` A {String} indicating how the tooltip should be displayed.
// Choose from one of the following options:
// * `'hover'` Show the tooltip when the mouse hovers over the element.
// This is the default.
// * `'click'` Show the tooltip when the element is clicked. The tooltip
// will be hidden after clicking the element again or anywhere else
// outside of the tooltip itself.
// * `'focus'` Show the tooltip when the element is focused.
// * `'manual'` Show the tooltip immediately and only hide it when the
// returned disposable is disposed.
// * `delay` An object specifying the show and hide delay in milliseconds.
// Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and
// otherwise defaults to `0` for both values.
// * `keyBindingCommand` A {String} containing a command name. If you specify
// this option and a key binding exists that matches the command, it will
// be appended to the title or rendered alone if no title is specified.
// * `keyBindingTarget` An `HTMLElement` on which to look up the key binding.
// If this option is not supplied, the first of all matching key bindings
// for the given command will be rendered.
//
// Returns a {Disposable} on which `.dispose()` can be called to remove the
// tooltip.
add (target, options) {
if (target.jquery) {
const disposable = new CompositeDisposable()
for (const element of target) { disposable.add(this.add(element, options)) }
return disposable
}
if (Tooltip == null) { Tooltip = require('./tooltip') }
const {keyBindingCommand, keyBindingTarget} = options
if (keyBindingCommand != null) {
const bindings = this.keymapManager.findKeyBindings({command: keyBindingCommand, target: keyBindingTarget})
const keystroke = getKeystroke(bindings)
if ((options.title != null) && (keystroke != null)) {
options.title += ` ${getKeystroke(bindings)}`
} else if (keystroke != null) {
options.title = getKeystroke(bindings)
}
}
delete options.selector
options = _.defaults(options, this.defaults)
if (options.trigger === 'hover') {
options = _.defaults(options, this.hoverDefaults)
}
const tooltip = new Tooltip(target, options, this.viewRegistry)
if (!this.tooltips.has(target)) {
this.tooltips.set(target, [])
}
this.tooltips.get(target).push(tooltip)
const hideTooltip = function () {
tooltip.leave({currentTarget: target})
tooltip.hide()
}
window.addEventListener('resize', hideTooltip)
const disposable = new Disposable(() => {
window.removeEventListener('resize', hideTooltip)
hideTooltip()
tooltip.destroy()
if (this.tooltips.has(target)) {
const tooltipsForTarget = this.tooltips.get(target)
const index = tooltipsForTarget.indexOf(tooltip)
if (index !== -1) {
tooltipsForTarget.splice(index, 1)
}
if (tooltipsForTarget.length === 0) {
this.tooltips.delete(target)
}
}
})
return disposable
}
// Extended: Find the tooltips that have been applied to the given element.
//
// * `target` The `HTMLElement` to find tooltips on.
//
// Returns an {Array} of `Tooltip` objects that match the `target`.
findTooltips (target) {
if (this.tooltips.has(target)) {
return this.tooltips.get(target).slice()
} else {
return []
}
}
}
function humanizeKeystrokes (keystroke) {
let keystrokes = keystroke.split(' ')
keystrokes = (keystrokes.map((stroke) => _.humanizeKeystroke(stroke)))
return keystrokes.join(' ')
}
function getKeystroke (bindings) {
if (bindings && bindings.length) {
return `<span class="keystroke">${humanizeKeystrokes(bindings[0].keystrokes)}</span>`
}
}

View File

@@ -1,201 +0,0 @@
Grim = require 'grim'
{Disposable} = require 'event-kit'
_ = require 'underscore-plus'
AnyConstructor = Symbol('any-constructor')
# Essential: `ViewRegistry` handles the association between model and view
# types in Atom. We call this association a View Provider. As in, for a given
# model, this class can provide a view via {::getView}, as long as the
# model/view association was registered via {::addViewProvider}
#
# If you're adding your own kind of pane item, a good strategy for all but the
# simplest items is to separate the model and the view. The model handles
# application logic and is the primary point of API interaction. The view
# just handles presentation.
#
# Note: Models can be any object, but must implement a `getTitle()` function
# if they are to be displayed in a {Pane}
#
# View providers inform the workspace how your model objects should be
# presented in the DOM. A view provider must always return a DOM node, which
# makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/)
# an ideal tool for implementing views in Atom.
#
# You can access the `ViewRegistry` object via `atom.views`.
module.exports =
class ViewRegistry
animationFrameRequest: null
documentReadInProgress: false
constructor: (@atomEnvironment) ->
@clear()
clear: ->
@views = new WeakMap
@providers = []
@clearDocumentRequests()
# Essential: Add a provider that will be used to construct views in the
# workspace's view layer based on model objects in its model layer.
#
# ## Examples
#
# Text editors are divided into a model and a view layer, so when you interact
# with methods like `atom.workspace.getActiveTextEditor()` you're only going
# to get the model object. We display text editors on screen by teaching the
# workspace what view constructor it should use to represent them:
#
# ```coffee
# atom.views.addViewProvider TextEditor, (textEditor) ->
# textEditorElement = new TextEditorElement
# textEditorElement.initialize(textEditor)
# textEditorElement
# ```
#
# * `modelConstructor` (optional) Constructor {Function} for your model. If
# a constructor is given, the `createView` function will only be used
# for model objects inheriting from that constructor. Otherwise, it will
# will be called for any object.
# * `createView` Factory {Function} that is passed an instance of your model
# and must return a subclass of `HTMLElement` or `undefined`. If it returns
# `undefined`, then the registry will continue to search for other view
# providers.
#
# Returns a {Disposable} on which `.dispose()` can be called to remove the
# added provider.
addViewProvider: (modelConstructor, createView) ->
if arguments.length is 1
switch typeof modelConstructor
when 'function'
provider = {createView: modelConstructor, modelConstructor: AnyConstructor}
when 'object'
Grim.deprecate("atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.")
provider = modelConstructor
else
throw new TypeError("Arguments to addViewProvider must be functions")
else
provider = {modelConstructor, createView}
@providers.push(provider)
new Disposable =>
@providers = @providers.filter (p) -> p isnt provider
getViewProviderCount: ->
@providers.length
# Essential: Get the view associated with an object in the workspace.
#
# If you're just *using* the workspace, you shouldn't need to access the view
# layer, but view layer access may be necessary if you want to perform DOM
# manipulation that isn't supported via the model API.
#
# ## View Resolution Algorithm
#
# The view associated with the object is resolved using the following
# sequence
#
# 1. Is the object an instance of `HTMLElement`? If true, return the object.
# 2. Does the object have a method named `getElement` that returns an
# instance of `HTMLElement`? If true, return that value.
# 3. Does the object have a property named `element` with a value which is
# an instance of `HTMLElement`? If true, return the property value.
# 4. Is the object a jQuery object, indicated by the presence of a `jquery`
# property? If true, return the root DOM element (i.e. `object[0]`).
# 5. Has a view provider been registered for the object? If true, use the
# provider to create a view associated with the object, and return the
# view.
#
# If no associated view is returned by the sequence an error is thrown.
#
# Returns a DOM element.
getView: (object) ->
return unless object?
if view = @views.get(object)
view
else
view = @createView(object)
@views.set(object, view)
view
createView: (object) ->
if object instanceof HTMLElement
return object
if typeof object?.getElement is 'function'
element = object.getElement()
if element instanceof HTMLElement
return element
if object?.element instanceof HTMLElement
return object.element
if object?.jquery
return object[0]
for provider in @providers
if provider.modelConstructor is AnyConstructor
if element = provider.createView(object, @atomEnvironment)
return element
continue
if object instanceof provider.modelConstructor
if element = provider.createView?(object, @atomEnvironment)
return element
if viewConstructor = provider.viewConstructor
element = new viewConstructor
element.initialize?(object) ? element.setModel?(object)
return element
if viewConstructor = object?.getViewClass?()
view = new viewConstructor(object)
return view[0]
throw new Error("Can't create a view for #{object.constructor.name} instance. Please register a view provider.")
updateDocument: (fn) ->
@documentWriters.push(fn)
@requestDocumentUpdate() unless @documentReadInProgress
new Disposable =>
@documentWriters = @documentWriters.filter (writer) -> writer isnt fn
readDocument: (fn) ->
@documentReaders.push(fn)
@requestDocumentUpdate()
new Disposable =>
@documentReaders = @documentReaders.filter (reader) -> reader isnt fn
getNextUpdatePromise: ->
@nextUpdatePromise ?= new Promise (resolve) =>
@resolveNextUpdatePromise = resolve
clearDocumentRequests: ->
@documentReaders = []
@documentWriters = []
@nextUpdatePromise = null
@resolveNextUpdatePromise = null
if @animationFrameRequest?
cancelAnimationFrame(@animationFrameRequest)
@animationFrameRequest = null
requestDocumentUpdate: ->
@animationFrameRequest ?= requestAnimationFrame(@performDocumentUpdate)
performDocumentUpdate: =>
resolveNextUpdatePromise = @resolveNextUpdatePromise
@animationFrameRequest = null
@nextUpdatePromise = null
@resolveNextUpdatePromise = null
writer() while writer = @documentWriters.shift()
@documentReadInProgress = true
reader() while reader = @documentReaders.shift()
@documentReadInProgress = false
# process updates requested as a result of reads
writer() while writer = @documentWriters.shift()
resolveNextUpdatePromise?()

259
src/view-registry.js Normal file
View File

@@ -0,0 +1,259 @@
const Grim = require('grim')
const {Disposable} = require('event-kit')
const AnyConstructor = Symbol('any-constructor')
// Essential: `ViewRegistry` handles the association between model and view
// types in Atom. We call this association a View Provider. As in, for a given
// model, this class can provide a view via {::getView}, as long as the
// model/view association was registered via {::addViewProvider}
//
// If you're adding your own kind of pane item, a good strategy for all but the
// simplest items is to separate the model and the view. The model handles
// application logic and is the primary point of API interaction. The view
// just handles presentation.
//
// Note: Models can be any object, but must implement a `getTitle()` function
// if they are to be displayed in a {Pane}
//
// View providers inform the workspace how your model objects should be
// presented in the DOM. A view provider must always return a DOM node, which
// makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/)
// an ideal tool for implementing views in Atom.
//
// You can access the `ViewRegistry` object via `atom.views`.
module.exports =
class ViewRegistry {
constructor (atomEnvironment) {
this.animationFrameRequest = null
this.documentReadInProgress = false
this.performDocumentUpdate = this.performDocumentUpdate.bind(this)
this.atomEnvironment = atomEnvironment
this.clear()
}
clear () {
this.views = new WeakMap()
this.providers = []
this.clearDocumentRequests()
}
// Essential: Add a provider that will be used to construct views in the
// workspace's view layer based on model objects in its model layer.
//
// ## Examples
//
// Text editors are divided into a model and a view layer, so when you interact
// with methods like `atom.workspace.getActiveTextEditor()` you're only going
// to get the model object. We display text editors on screen by teaching the
// workspace what view constructor it should use to represent them:
//
// ```coffee
// atom.views.addViewProvider TextEditor, (textEditor) ->
// textEditorElement = new TextEditorElement
// textEditorElement.initialize(textEditor)
// textEditorElement
// ```
//
// * `modelConstructor` (optional) Constructor {Function} for your model. If
// a constructor is given, the `createView` function will only be used
// for model objects inheriting from that constructor. Otherwise, it will
// will be called for any object.
// * `createView` Factory {Function} that is passed an instance of your model
// and must return a subclass of `HTMLElement` or `undefined`. If it returns
// `undefined`, then the registry will continue to search for other view
// providers.
//
// Returns a {Disposable} on which `.dispose()` can be called to remove the
// added provider.
addViewProvider (modelConstructor, createView) {
let provider
if (arguments.length === 1) {
switch (typeof modelConstructor) {
case 'function':
provider = {createView: modelConstructor, modelConstructor: AnyConstructor}
break
case 'object':
Grim.deprecate('atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.')
provider = modelConstructor
break
default:
throw new TypeError('Arguments to addViewProvider must be functions')
}
} else {
provider = {modelConstructor, createView}
}
this.providers.push(provider)
return new Disposable(() => {
this.providers = this.providers.filter(p => p !== provider)
})
}
getViewProviderCount () {
return this.providers.length
}
// Essential: Get the view associated with an object in the workspace.
//
// If you're just *using* the workspace, you shouldn't need to access the view
// layer, but view layer access may be necessary if you want to perform DOM
// manipulation that isn't supported via the model API.
//
// ## View Resolution Algorithm
//
// The view associated with the object is resolved using the following
// sequence
//
// 1. Is the object an instance of `HTMLElement`? If true, return the object.
// 2. Does the object have a method named `getElement` that returns an
// instance of `HTMLElement`? If true, return that value.
// 3. Does the object have a property named `element` with a value which is
// an instance of `HTMLElement`? If true, return the property value.
// 4. Is the object a jQuery object, indicated by the presence of a `jquery`
// property? If true, return the root DOM element (i.e. `object[0]`).
// 5. Has a view provider been registered for the object? If true, use the
// provider to create a view associated with the object, and return the
// view.
//
// If no associated view is returned by the sequence an error is thrown.
//
// Returns a DOM element.
getView (object) {
if (object == null) { return }
let view = this.views.get(object)
if (!view) {
view = this.createView(object)
this.views.set(object, view)
}
return view
}
createView (object) {
if (object instanceof HTMLElement) { return object }
let element
if (object && (typeof object.getElement === 'function')) {
element = object.getElement()
if (element instanceof HTMLElement) {
return element
}
}
if (object && object.element instanceof HTMLElement) {
return object.element
}
if (object && object.jquery) {
return object[0]
}
for (let provider of this.providers) {
if (provider.modelConstructor === AnyConstructor) {
element = provider.createView(object, this.atomEnvironment)
if (element) { return element }
continue
}
if (object instanceof provider.modelConstructor) {
element = provider.createView && provider.createView(object, this.atomEnvironment)
if (element) { return element }
let ViewConstructor = provider.viewConstructor
if (ViewConstructor) {
element = new ViewConstructor()
if (element.initialize) {
element.initialize(object)
} else if (element.setModel) {
element.setModel(object)
}
return element
}
}
}
if (object && object.getViewClass) {
let ViewConstructor = object.getViewClass()
if (ViewConstructor) {
const view = new ViewConstructor(object)
return view[0]
}
}
throw new Error(`Can't create a view for ${object.constructor.name} instance. Please register a view provider.`)
}
updateDocument (fn) {
this.documentWriters.push(fn)
if (!this.documentReadInProgress) { this.requestDocumentUpdate() }
return new Disposable(() => {
this.documentWriters = this.documentWriters.filter(writer => writer !== fn)
})
}
readDocument (fn) {
this.documentReaders.push(fn)
this.requestDocumentUpdate()
return new Disposable(() => {
this.documentReaders = this.documentReaders.filter(reader => reader !== fn)
})
}
getNextUpdatePromise () {
if (this.nextUpdatePromise == null) {
this.nextUpdatePromise = new Promise(resolve => {
this.resolveNextUpdatePromise = resolve
})
}
return this.nextUpdatePromise
}
clearDocumentRequests () {
this.documentReaders = []
this.documentWriters = []
this.nextUpdatePromise = null
this.resolveNextUpdatePromise = null
if (this.animationFrameRequest != null) {
cancelAnimationFrame(this.animationFrameRequest)
this.animationFrameRequest = null
}
}
requestDocumentUpdate () {
if (this.animationFrameRequest == null) {
this.animationFrameRequest = requestAnimationFrame(this.performDocumentUpdate)
}
}
performDocumentUpdate () {
const { resolveNextUpdatePromise } = this
this.animationFrameRequest = null
this.nextUpdatePromise = null
this.resolveNextUpdatePromise = null
var writer = this.documentWriters.shift()
while (writer) {
writer()
writer = this.documentWriters.shift()
}
var reader = this.documentReaders.shift()
this.documentReadInProgress = true
while (reader) {
reader()
reader = this.documentReaders.shift()
}
this.documentReadInProgress = false
// process updates requested as a result of reads
writer = this.documentWriters.shift()
while (writer) {
writer()
writer = this.documentWriters.shift()
}
if (resolveNextUpdatePromise) { resolveNextUpdatePromise() }
}
}

View File

@@ -1,189 +0,0 @@
{Disposable, CompositeDisposable} = require 'event-kit'
listen = require './delegated-listener'
# Handles low-level events related to the @window.
module.exports =
class WindowEventHandler
constructor: ({@atomEnvironment, @applicationDelegate}) ->
@reloadRequested = false
@subscriptions = new CompositeDisposable
@handleNativeKeybindings()
initialize: (@window, @document) ->
@subscriptions.add @atomEnvironment.commands.add @window,
'window:toggle-full-screen': @handleWindowToggleFullScreen
'window:close': @handleWindowClose
'window:reload': @handleWindowReload
'window:toggle-dev-tools': @handleWindowToggleDevTools
if process.platform in ['win32', 'linux']
@subscriptions.add @atomEnvironment.commands.add @window,
'window:toggle-menu-bar': @handleWindowToggleMenuBar
@subscriptions.add @atomEnvironment.commands.add @document,
'core:focus-next': @handleFocusNext
'core:focus-previous': @handleFocusPrevious
@addEventListener(@window, 'beforeunload', @handleWindowBeforeunload)
@addEventListener(@window, 'focus', @handleWindowFocus)
@addEventListener(@window, 'blur', @handleWindowBlur)
@addEventListener(@document, 'keyup', @handleDocumentKeyEvent)
@addEventListener(@document, 'keydown', @handleDocumentKeyEvent)
@addEventListener(@document, 'drop', @handleDocumentDrop)
@addEventListener(@document, 'dragover', @handleDocumentDragover)
@addEventListener(@document, 'contextmenu', @handleDocumentContextmenu)
@subscriptions.add listen(@document, 'click', 'a', @handleLinkClick)
@subscriptions.add listen(@document, 'submit', 'form', @handleFormSubmit)
@subscriptions.add(@applicationDelegate.onDidEnterFullScreen(@handleEnterFullScreen))
@subscriptions.add(@applicationDelegate.onDidLeaveFullScreen(@handleLeaveFullScreen))
# Wire commands that should be handled by Chromium for elements with the
# `.native-key-bindings` class.
handleNativeKeybindings: ->
bindCommandToAction = (command, action) =>
@subscriptions.add @atomEnvironment.commands.add(
'.native-key-bindings',
command,
((event) => @applicationDelegate.getCurrentWindow().webContents[action]()),
false
)
bindCommandToAction('core:copy', 'copy')
bindCommandToAction('core:paste', 'paste')
bindCommandToAction('core:undo', 'undo')
bindCommandToAction('core:redo', 'redo')
bindCommandToAction('core:select-all', 'selectAll')
bindCommandToAction('core:cut', 'cut')
unsubscribe: ->
@subscriptions.dispose()
on: (target, eventName, handler) ->
target.on(eventName, handler)
@subscriptions.add(new Disposable ->
target.removeListener(eventName, handler)
)
addEventListener: (target, eventName, handler) ->
target.addEventListener(eventName, handler)
@subscriptions.add(new Disposable(-> target.removeEventListener(eventName, handler)))
handleDocumentKeyEvent: (event) =>
@atomEnvironment.keymaps.handleKeyboardEvent(event)
event.stopImmediatePropagation()
handleDrop: (event) ->
event.preventDefault()
event.stopPropagation()
handleDragover: (event) ->
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'none'
eachTabIndexedElement: (callback) ->
for element in @document.querySelectorAll('[tabindex]')
continue if element.disabled
continue unless element.tabIndex >= 0
callback(element, element.tabIndex)
return
handleFocusNext: =>
focusedTabIndex = @document.activeElement.tabIndex ? -Infinity
nextElement = null
nextTabIndex = Infinity
lowestElement = null
lowestTabIndex = Infinity
@eachTabIndexedElement (element, tabIndex) ->
if tabIndex < lowestTabIndex
lowestTabIndex = tabIndex
lowestElement = element
if focusedTabIndex < tabIndex < nextTabIndex
nextTabIndex = tabIndex
nextElement = element
if nextElement?
nextElement.focus()
else if lowestElement?
lowestElement.focus()
handleFocusPrevious: =>
focusedTabIndex = @document.activeElement.tabIndex ? Infinity
previousElement = null
previousTabIndex = -Infinity
highestElement = null
highestTabIndex = -Infinity
@eachTabIndexedElement (element, tabIndex) ->
if tabIndex > highestTabIndex
highestTabIndex = tabIndex
highestElement = element
if focusedTabIndex > tabIndex > previousTabIndex
previousTabIndex = tabIndex
previousElement = element
if previousElement?
previousElement.focus()
else if highestElement?
highestElement.focus()
handleWindowFocus: ->
@document.body.classList.remove('is-blurred')
handleWindowBlur: =>
@document.body.classList.add('is-blurred')
@atomEnvironment.storeWindowDimensions()
handleEnterFullScreen: =>
@document.body.classList.add("fullscreen")
handleLeaveFullScreen: =>
@document.body.classList.remove("fullscreen")
handleWindowBeforeunload: (event) =>
if not @reloadRequested and not @atomEnvironment.inSpecMode() and @atomEnvironment.getCurrentWindow().isWebViewFocused()
@atomEnvironment.hide()
@reloadRequested = false
@atomEnvironment.storeWindowDimensions()
@atomEnvironment.unloadEditorWindow()
@atomEnvironment.destroy()
handleWindowToggleFullScreen: =>
@atomEnvironment.toggleFullScreen()
handleWindowClose: =>
@atomEnvironment.close()
handleWindowReload: =>
@reloadRequested = true
@atomEnvironment.reload()
handleWindowToggleDevTools: =>
@atomEnvironment.toggleDevTools()
handleWindowToggleMenuBar: =>
@atomEnvironment.config.set('core.autoHideMenuBar', not @atomEnvironment.config.get('core.autoHideMenuBar'))
if @atomEnvironment.config.get('core.autoHideMenuBar')
detail = "To toggle, press the Alt key or execute the window:toggle-menu-bar command"
@atomEnvironment.notifications.addInfo('Menu bar hidden', {detail})
handleLinkClick: (event) =>
event.preventDefault()
uri = event.currentTarget?.getAttribute('href')
if uri and uri[0] isnt '#' and /^https?:\/\//.test(uri)
@applicationDelegate.openExternal(uri)
handleFormSubmit: (event) ->
# Prevent form submits from changing the current window's URL
event.preventDefault()
handleDocumentContextmenu: (event) =>
event.preventDefault()
@atomEnvironment.contextMenu.showForEvent(event)

253
src/window-event-handler.js Normal file
View File

@@ -0,0 +1,253 @@
const {Disposable, CompositeDisposable} = require('event-kit')
const listen = require('./delegated-listener')
// Handles low-level events related to the `window`.
module.exports =
class WindowEventHandler {
constructor ({atomEnvironment, applicationDelegate}) {
this.handleDocumentKeyEvent = this.handleDocumentKeyEvent.bind(this)
this.handleFocusNext = this.handleFocusNext.bind(this)
this.handleFocusPrevious = this.handleFocusPrevious.bind(this)
this.handleWindowBlur = this.handleWindowBlur.bind(this)
this.handleEnterFullScreen = this.handleEnterFullScreen.bind(this)
this.handleLeaveFullScreen = this.handleLeaveFullScreen.bind(this)
this.handleWindowBeforeunload = this.handleWindowBeforeunload.bind(this)
this.handleWindowToggleFullScreen = this.handleWindowToggleFullScreen.bind(this)
this.handleWindowClose = this.handleWindowClose.bind(this)
this.handleWindowReload = this.handleWindowReload.bind(this)
this.handleWindowToggleDevTools = this.handleWindowToggleDevTools.bind(this)
this.handleWindowToggleMenuBar = this.handleWindowToggleMenuBar.bind(this)
this.handleLinkClick = this.handleLinkClick.bind(this)
this.handleDocumentContextmenu = this.handleDocumentContextmenu.bind(this)
this.atomEnvironment = atomEnvironment
this.applicationDelegate = applicationDelegate
this.reloadRequested = false
this.subscriptions = new CompositeDisposable()
this.handleNativeKeybindings()
}
initialize (window, document) {
this.window = window
this.document = document
this.subscriptions.add(this.atomEnvironment.commands.add(this.window, {
'window:toggle-full-screen': this.handleWindowToggleFullScreen,
'window:close': this.handleWindowClose,
'window:reload': this.handleWindowReload,
'window:toggle-dev-tools': this.handleWindowToggleDevTools
}))
if (['win32', 'linux'].includes(process.platform)) {
this.subscriptions.add(this.atomEnvironment.commands.add(this.window,
{'window:toggle-menu-bar': this.handleWindowToggleMenuBar})
)
}
this.subscriptions.add(this.atomEnvironment.commands.add(this.document, {
'core:focus-next': this.handleFocusNext,
'core:focus-previous': this.handleFocusPrevious
}))
this.addEventListener(this.window, 'beforeunload', this.handleWindowBeforeunload)
this.addEventListener(this.window, 'focus', this.handleWindowFocus)
this.addEventListener(this.window, 'blur', this.handleWindowBlur)
this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent)
this.addEventListener(this.document, 'keydown', this.handleDocumentKeyEvent)
this.addEventListener(this.document, 'drop', this.handleDocumentDrop)
this.addEventListener(this.document, 'dragover', this.handleDocumentDragover)
this.addEventListener(this.document, 'contextmenu', this.handleDocumentContextmenu)
this.subscriptions.add(listen(this.document, 'click', 'a', this.handleLinkClick))
this.subscriptions.add(listen(this.document, 'submit', 'form', this.handleFormSubmit))
this.subscriptions.add(this.applicationDelegate.onDidEnterFullScreen(this.handleEnterFullScreen))
this.subscriptions.add(this.applicationDelegate.onDidLeaveFullScreen(this.handleLeaveFullScreen))
}
// Wire commands that should be handled by Chromium for elements with the
// `.native-key-bindings` class.
handleNativeKeybindings () {
const bindCommandToAction = (command, action) => {
this.subscriptions.add(
this.atomEnvironment.commands.add(
'.native-key-bindings',
command,
event => this.applicationDelegate.getCurrentWindow().webContents[action](),
false
)
)
}
bindCommandToAction('core:copy', 'copy')
bindCommandToAction('core:paste', 'paste')
bindCommandToAction('core:undo', 'undo')
bindCommandToAction('core:redo', 'redo')
bindCommandToAction('core:select-all', 'selectAll')
bindCommandToAction('core:cut', 'cut')
}
unsubscribe () {
this.subscriptions.dispose()
}
on (target, eventName, handler) {
target.on(eventName, handler)
this.subscriptions.add(new Disposable(function () {
target.removeListener(eventName, handler)
}))
}
addEventListener (target, eventName, handler) {
target.addEventListener(eventName, handler)
this.subscriptions.add(new Disposable(function () {
target.removeEventListener(eventName, handler)
}))
}
handleDocumentKeyEvent (event) {
this.atomEnvironment.keymaps.handleKeyboardEvent(event)
event.stopImmediatePropagation()
}
handleDrop (event) {
event.preventDefault()
event.stopPropagation()
}
handleDragover (event) {
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'none'
}
eachTabIndexedElement (callback) {
for (let element of this.document.querySelectorAll('[tabindex]')) {
if (element.disabled) { continue }
if (!(element.tabIndex >= 0)) { continue }
callback(element, element.tabIndex)
}
}
handleFocusNext () {
const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : -Infinity
let nextElement = null
let nextTabIndex = Infinity
let lowestElement = null
let lowestTabIndex = Infinity
this.eachTabIndexedElement(function (element, tabIndex) {
if (tabIndex < lowestTabIndex) {
lowestTabIndex = tabIndex
lowestElement = element
}
if (focusedTabIndex < tabIndex && tabIndex < nextTabIndex) {
nextTabIndex = tabIndex
nextElement = element
}
})
if (nextElement != null) {
nextElement.focus()
} else if (lowestElement != null) {
lowestElement.focus()
}
}
handleFocusPrevious () {
const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : Infinity
let previousElement = null
let previousTabIndex = -Infinity
let highestElement = null
let highestTabIndex = -Infinity
this.eachTabIndexedElement(function (element, tabIndex) {
if (tabIndex > highestTabIndex) {
highestTabIndex = tabIndex
highestElement = element
}
if (focusedTabIndex > tabIndex && tabIndex > previousTabIndex) {
previousTabIndex = tabIndex
previousElement = element
}
})
if (previousElement != null) {
previousElement.focus()
} else if (highestElement != null) {
highestElement.focus()
}
}
handleWindowFocus () {
this.document.body.classList.remove('is-blurred')
}
handleWindowBlur () {
this.document.body.classList.add('is-blurred')
this.atomEnvironment.storeWindowDimensions()
}
handleEnterFullScreen () {
this.document.body.classList.add('fullscreen')
}
handleLeaveFullScreen () {
this.document.body.classList.remove('fullscreen')
}
handleWindowBeforeunload (event) {
if (!this.reloadRequested && !this.atomEnvironment.inSpecMode() && this.atomEnvironment.getCurrentWindow().isWebViewFocused()) {
this.atomEnvironment.hide()
}
this.reloadRequested = false
this.atomEnvironment.storeWindowDimensions()
this.atomEnvironment.unloadEditorWindow()
this.atomEnvironment.destroy()
}
handleWindowToggleFullScreen () {
this.atomEnvironment.toggleFullScreen()
}
handleWindowClose () {
this.atomEnvironment.close()
}
handleWindowReload () {
this.reloadRequested = true
this.atomEnvironment.reload()
}
handleWindowToggleDevTools () {
this.atomEnvironment.toggleDevTools()
}
handleWindowToggleMenuBar () {
this.atomEnvironment.config.set('core.autoHideMenuBar', !this.atomEnvironment.config.get('core.autoHideMenuBar'))
if (this.atomEnvironment.config.get('core.autoHideMenuBar')) {
const detail = 'To toggle, press the Alt key or execute the window:toggle-menu-bar command'
this.atomEnvironment.notifications.addInfo('Menu bar hidden', {detail})
}
}
handleLinkClick (event) {
event.preventDefault()
const uri = event.currentTarget && event.currentTarget.getAttribute('href')
if (uri && (uri[0] !== '#') && /^https?:\/\//.test(uri)) {
this.applicationDelegate.openExternal(uri)
}
}
handleFormSubmit (event) {
// Prevent form submits from changing the current window's URL
event.preventDefault()
}
handleDocumentContextmenu (event) {
event.preventDefault()
this.atomEnvironment.contextMenu.showForEvent(event)
}
}

View File

@@ -659,7 +659,7 @@ module.exports = class Workspace extends Model {
// changing or closing tabs and ensures critical UI feedback, like changing the
// highlighted tab, gets priority over work that can be done asynchronously.
//
// * `callback` {Function} to be called when the active pane item stopts
// * `callback` {Function} to be called when the active pane item stops
// changing.
// * `item` The active pane item.
//
@@ -1050,10 +1050,10 @@ module.exports = class Workspace extends Model {
// Essential: Search the workspace for items matching the given URI and hide them.
//
// * `itemOrURI` (optional) The item to hide or a {String} containing the URI
// * `itemOrURI` The item to hide or a {String} containing the URI
// of the item to hide.
//
// Returns a {boolean} indicating whether any items were found (and hidden).
// Returns a {Boolean} indicating whether any items were found (and hidden).
hide (itemOrURI) {
let foundItems = false