diff --git a/apm/package.json b/apm/package.json index 5391c9972..336544d3e 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.18.8" + "atom-package-manager": "1.18.10" } } diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index fa942d97c..7161a8478 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -132,6 +132,7 @@ 'ctrl-shift-w': 'editor:select-word' 'cmd-ctrl-left': 'editor:move-selection-left' 'cmd-ctrl-right': 'editor:move-selection-right' + 'cmd-shift-V': 'editor:paste-without-reformatting' # Emacs 'alt-f': 'editor:move-to-end-of-word' diff --git a/keymaps/linux.cson b/keymaps/linux.cson index d6ded1f90..9d3e4dbb1 100644 --- a/keymaps/linux.cson +++ b/keymaps/linux.cson @@ -105,6 +105,7 @@ 'alt-shift-right': 'editor:select-to-next-subword-boundary' 'alt-backspace': 'editor:delete-to-beginning-of-subword' 'alt-delete': 'editor:delete-to-end-of-subword' + 'ctrl-shift-V': 'editor:paste-without-reformatting' # Sublime Parity 'ctrl-a': 'core:select-all' diff --git a/keymaps/win32.cson b/keymaps/win32.cson index 14f5a4283..8a8e92249 100644 --- a/keymaps/win32.cson +++ b/keymaps/win32.cson @@ -110,6 +110,7 @@ 'alt-shift-right': 'editor:select-to-next-subword-boundary' 'alt-backspace': 'editor:delete-to-beginning-of-subword' 'alt-delete': 'editor:delete-to-end-of-subword' + 'ctrl-shift-V': 'editor:paste-without-reformatting' # Sublime Parity 'ctrl-a': 'core:select-all' diff --git a/menus/darwin.cson b/menus/darwin.cson index 055cd2405..2dffda1ef 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -65,6 +65,7 @@ { label: 'Copy', command: 'core:copy' } { label: 'Copy Path', command: 'editor:copy-path' } { label: 'Paste', command: 'core:paste' } + { label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' } { label: 'Select All', command: 'core:select-all' } { type: 'separator' } { label: 'Toggle Comments', command: 'editor:toggle-line-comments' } diff --git a/menus/linux.cson b/menus/linux.cson index 2a1ca47f8..b44900398 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -38,6 +38,7 @@ { label: 'C&opy', command: 'core:copy' } { label: 'Copy Pat&h', command: 'editor:copy-path' } { label: '&Paste', command: 'core:paste' } + { label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' } { label: 'Select &All', command: 'core:select-all' } { type: 'separator' } { label: '&Toggle Comments', command: 'editor:toggle-line-comments' } diff --git a/menus/win32.cson b/menus/win32.cson index 553b6017e..a921bae74 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -46,6 +46,7 @@ { label: '&Copy', command: 'core:copy' } { label: 'Copy Pat&h', command: 'editor:copy-path' } { label: '&Paste', command: 'core:paste' } + { label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' } { label: 'Select &All', command: 'core:select-all' } { type: 'separator' } { label: '&Toggle Comments', command: 'editor:toggle-line-comments' } diff --git a/package.json b/package.json index dc454e3ab..87b37cfb1 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "license": "MIT", "electronVersion": "1.6.15", "dependencies": { + "@atom/nsfw": "^1.0.18", "@atom/source-map-support": "^0.3.4", "async": "0.2.6", - "atom-keymap": "8.2.7", + "atom-keymap": "8.2.8", "atom-select-list": "^0.1.0", "atom-ui": "0.4.1", "babel-core": "5.8.38", @@ -24,14 +25,14 @@ "chai": "3.5.0", "chart.js": "^2.3.0", "clear-cut": "^2.0.2", - "coffee-script": "1.11.1", + "coffee-script": "1.12.7", "color": "^0.7.3", - "dedent": "^0.6.0", + "dedent": "^0.7.0", "devtron": "1.3.0", "etch": "^0.12.6", "event-kit": "^2.4.0", "find-parent-dir": "^0.3.0", - "first-mate": "7.0.9", + "first-mate": "7.1.0", "focus-trap": "^2.3.0", "fs-admin": "^0.1.6", "fs-plus": "^3.0.1", @@ -53,7 +54,6 @@ "mocha-multi-reporters": "^1.1.4", "mock-spawn": "^0.2.6", "normalize-package-data": "^2.0.0", - "nsfw": "^1.0.15", "nslog": "^3", "oniguruma": "6.2.1", "pathwatcher": "8.0.1", @@ -65,12 +65,12 @@ "scandal": "^3.1.0", "scoped-property-store": "^0.17.0", "scrollbar-style": "^3.2", - "season": "^6.0.1", + "season": "^6.0.2", "semver": "^4.3.3", "service-hub": "^0.7.4", "sinon": "1.17.4", "temp": "^0.8.3", - "text-buffer": "13.5.7", + "text-buffer": "13.8.1", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -90,85 +90,85 @@ "solarized-dark-syntax": "1.1.2", "solarized-light-syntax": "1.1.2", "about": "1.7.8", - "archive-view": "0.63.4", - "autocomplete-atom-api": "0.10.3", - "autocomplete-css": "0.17.3", - "autocomplete-html": "0.8.2", - "autocomplete-plus": "2.36.7", - "autocomplete-snippets": "1.11.1", + "archive-view": "0.64.1", + "autocomplete-atom-api": "0.10.5", + "autocomplete-css": "0.17.4", + "autocomplete-html": "0.8.3", + "autocomplete-plus": "2.37.2", + "autocomplete-snippets": "1.11.2", "autoflow": "0.29.0", "autosave": "0.24.6", "background-tips": "0.27.1", "bookmarks": "0.44.4", "bracket-matcher": "0.88.0", - "command-palette": "0.41.1", + "command-palette": "0.42.0", "dalek": "0.2.1", "deprecation-cop": "0.56.9", "dev-live-reload": "0.47.1", "encoding-selector": "0.23.7", - "exception-reporting": "0.41.4", - "find-and-replace": "0.212.3", - "fuzzy-finder": "1.6.1", - "github": "0.7.0", + "exception-reporting": "0.41.5", + "find-and-replace": "0.213.0", + "fuzzy-finder": "1.7.2", + "github": "0.8.0", "git-diff": "1.3.6", "go-to-line": "0.32.1", - "grammar-selector": "0.49.6", + "grammar-selector": "0.49.8", "image-view": "0.62.4", "incompatible-packages": "0.27.3", - "keybinding-resolver": "0.38.0", + "keybinding-resolver": "0.38.1", "line-ending-selector": "0.7.4", "link": "0.31.3", - "markdown-preview": "0.159.15", + "markdown-preview": "0.159.18", "metrics": "1.2.6", "notifications": "0.69.2", - "open-on-github": "1.2.1", + "open-on-github": "1.3.0", "package-generator": "1.1.1", - "settings-view": "0.252.0", - "snippets": "1.1.5", + "settings-view": "0.253.0", + "snippets": "1.1.9", "spell-check": "0.72.3", - "status-bar": "1.8.13", - "styleguide": "0.49.7", + "status-bar": "1.8.14", + "styleguide": "0.49.8", "symbols-view": "0.118.1", - "tabs": "0.107.4", + "tabs": "0.109.1", "timecop": "0.36.0", - "tree-view": "0.219.0", + "tree-view": "0.221.1", "update-package-dependencies": "0.12.0", "welcome": "0.36.5", - "whitespace": "0.37.4", + "whitespace": "0.37.5", "wrap-guide": "0.40.2", "language-c": "0.58.1", "language-clojure": "0.22.4", - "language-coffee-script": "0.49.1", + "language-coffee-script": "0.49.3", "language-csharp": "0.14.3", - "language-css": "0.42.6", - "language-gfm": "0.90.1", + "language-css": "0.42.7", + "language-gfm": "0.90.2", "language-git": "0.19.1", - "language-go": "0.44.2", - "language-html": "0.48.1", - "language-hyperlink": "0.16.2", - "language-java": "0.27.4", - "language-javascript": "0.127.5", + "language-go": "0.44.3", + "language-html": "0.48.2", + "language-hyperlink": "0.16.3", + "language-java": "0.27.6", + "language-javascript": "0.127.6", "language-json": "0.19.1", "language-less": "0.33.0", "language-make": "0.22.3", - "language-mustache": "0.14.3", + "language-mustache": "0.14.4", "language-objective-c": "0.15.1", - "language-perl": "0.37.0", - "language-php": "0.42.1", + "language-perl": "0.38.1", + "language-php": "0.42.2", "language-property-list": "0.9.1", - "language-python": "0.45.4", - "language-ruby": "0.71.3", + "language-python": "0.45.5", + "language-ruby": "0.71.4", "language-ruby-on-rails": "0.25.2", "language-sass": "0.61.1", - "language-shellscript": "0.25.3", + "language-shellscript": "0.25.4", "language-source": "0.9.0", "language-sql": "0.25.8", "language-text": "0.7.3", - "language-todo": "0.29.2", + "language-todo": "0.29.3", "language-toml": "0.18.1", "language-typescript": "0.2.2", "language-xml": "0.35.2", - "language-yaml": "0.31.0" + "language-yaml": "0.31.1" }, "private": true, "scripts": { diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 2905bca1b..333acdc0a 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -27,47 +27,37 @@ module.exports = function (packagedAppPath) { coreModules.has(modulePath) || (relativePath.startsWith(path.join('..', 'src')) && relativePath.endsWith('-element.js')) || relativePath.startsWith(path.join('..', 'node_modules', 'dugite')) || + relativePath.endsWith(path.join('node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js')) || + relativePath.endsWith(path.join('node_modules', 'fs-extra', 'lib', 'index.js')) || + relativePath.endsWith(path.join('node_modules', 'graceful-fs', 'graceful-fs.js')) || + relativePath.endsWith(path.join('node_modules', 'htmlparser2', 'lib', 'index.js')) || + relativePath.endsWith(path.join('node_modules', 'minimatch', 'minimatch.js')) || relativePath === path.join('..', 'exports', 'atom.js') || relativePath === path.join('..', 'src', 'electron-shims.js') || relativePath === path.join('..', 'src', 'safe-clipboard.js') || relativePath === path.join('..', 'node_modules', 'atom-keymap', 'lib', 'command-event.js') || relativePath === path.join('..', 'node_modules', 'babel-core', 'index.js') || relativePath === path.join('..', 'node_modules', 'cached-run-in-this-context', 'lib', 'main.js') || - relativePath === path.join('..', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') || - relativePath === path.join('..', 'node_modules', 'cson-parser', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') || relativePath === path.join('..', 'node_modules', 'decompress-zip', 'lib', 'decompress-zip.js') || relativePath === path.join('..', 'node_modules', 'debug', 'node.js') || - relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') || - relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'git-utils', 'src', 'git.js') || relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') || - relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') || - relativePath === path.join('..', 'node_modules', 'htmlparser2', 'lib', 'index.js') || - relativePath === path.join('..', 'node_modules', 'markdown-preview', 'node_modules', 'htmlparser2', 'lib', 'index.js') || - relativePath === path.join('..', 'node_modules', 'roaster', 'node_modules', 'htmlparser2', 'lib', 'index.js') || - relativePath === path.join('..', 'node_modules', 'task-lists', 'node_modules', 'htmlparser2', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'iconv-lite', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'less', 'index.js') || relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less', 'fs.js') || relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less-node', 'index.js') || - relativePath === path.join('..', 'node_modules', 'less', 'node_modules', 'graceful-fs', 'graceful-fs.js') || - relativePath === path.join('..', 'node_modules', 'minimatch', 'minimatch.js') || relativePath === path.join('..', 'node_modules', 'node-fetch', 'lib', 'fetch-error.js') || - relativePath === path.join('..', 'node_modules', 'nsfw', 'node_modules', 'fs-extra', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'superstring', 'index.js') || relativePath === path.join('..', 'node_modules', 'oniguruma', 'src', 'oniguruma.js') || relativePath === path.join('..', 'node_modules', 'request', 'index.js') || relativePath === path.join('..', 'node_modules', 'resolve', 'index.js') || relativePath === path.join('..', 'node_modules', 'resolve', 'lib', 'core.js') || - relativePath === path.join('..', 'node_modules', 'scandal', 'node_modules', 'minimatch', 'minimatch.js') || relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'glob', 'glob.js') || - relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'minimatch', 'minimatch.js') || relativePath === path.join('..', 'node_modules', 'spellchecker', 'lib', 'spellchecker.js') || relativePath === path.join('..', 'node_modules', 'spelling-manager', 'node_modules', 'natural', 'lib', 'natural', 'index.js') || relativePath === path.join('..', 'node_modules', 'tar', 'tar.js') || relativePath === path.join('..', 'node_modules', 'temp', 'lib', 'temp.js') || - relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') || - relativePath === path.join('..', 'node_modules', 'tree-view', 'node_modules', 'minimatch', 'minimatch.js') + relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') ) } }).then((snapshotScript) => { diff --git a/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson index 8b4d85412..37aac3d4d 100644 --- a/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson +++ b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson @@ -1,5 +1,6 @@ 'name': 'Test Ruby' 'scopeName': 'test.rb' +'firstLineMatch': '^\\#!.*(?:\\s|\\/)(?:testruby)(?:$|\\s)' 'fileTypes': [ 'rb' ] diff --git a/spec/grammars-spec.coffee b/spec/grammars-spec.coffee index 7b70797ba..db716528d 100644 --- a/spec/grammars-spec.coffee +++ b/spec/grammars-spec.coffee @@ -120,6 +120,8 @@ describe "the `grammars` global", -> atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true atom.grammars.grammarForScopeName('test.rb').bundledPackage = false + expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env ruby').scopeName).toBe 'source.ruby' + expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env testruby').scopeName).toBe 'test.rb' expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe 'test.rb' describe "when there is no file path", -> diff --git a/spec/menu-manager-spec.coffee b/spec/menu-manager-spec.coffee index 798aa3766..3bbd8b9da 100644 --- a/spec/menu-manager-spec.coffee +++ b/spec/menu-manager-spec.coffee @@ -6,6 +6,7 @@ describe "MenuManager", -> beforeEach -> menu = new MenuManager({keymapManager: atom.keymaps, packageManager: atom.packages}) + spyOn(menu, 'sendToBrowserProcess') # Do not modify Atom's actual menus menu.initialize({resourcePath: atom.getLoadSettings().resourcePath}) describe "::add(items)", -> @@ -54,7 +55,6 @@ describe "MenuManager", -> afterEach -> Object.defineProperty process, 'platform', value: originalPlatform it "sends the current menu template and associated key bindings to the browser process", -> - spyOn(menu, 'sendToBrowserProcess') menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b' menu.update() @@ -66,7 +66,6 @@ describe "MenuManager", -> it "omits key bindings that are mapped to unset! in any context", -> # it would be nice to be smarter about omitting, but that would require a much # more dynamic interaction between the currently focused element and the menu - spyOn(menu, 'sendToBrowserProcess') menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b' atom.keymaps.add 'test', 'atom-text-editor': 'ctrl-b': 'unset!' @@ -77,7 +76,6 @@ describe "MenuManager", -> it "omits key bindings that could conflict with AltGraph characters on macOS", -> Object.defineProperty process, 'platform', value: 'darwin' - spyOn(menu, 'sendToBrowserProcess') menu.add [{label: "A", submenu: [ {label: "B", command: "b"}, {label: "C", command: "c"} @@ -98,7 +96,6 @@ describe "MenuManager", -> it "omits key bindings that could conflict with AltGraph characters on Windows", -> Object.defineProperty process, 'platform', value: 'win32' - spyOn(menu, 'sendToBrowserProcess') menu.add [{label: "A", submenu: [ {label: "B", command: "b"}, {label: "C", command: "c"} diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee deleted file mode 100644 index 1f5eb54a4..000000000 --- a/spec/project-spec.coffee +++ /dev/null @@ -1,802 +0,0 @@ -temp = require('temp').track() -TextBuffer = require('text-buffer') -Project = require '../src/project' -fs = require 'fs-plus' -path = require 'path' -{Directory} = require 'pathwatcher' -{stopAllWatchers} = require '../src/path-watcher' -GitRepository = require '../src/git-repository' - -describe "Project", -> - beforeEach -> - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')]) - - # Wait for project's service consumers to be asynchronously added - waits(1) - - describe "serialization", -> - deserializedProject = null - notQuittingProject = null - quittingProject = null - - afterEach -> - deserializedProject?.destroy() - notQuittingProject?.destroy() - quittingProject?.destroy() - - it "does not deserialize paths to directories that don't exist", -> - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - state = atom.project.serialize() - state.paths.push('/directory/that/does/not/exist') - - err = null - waitsForPromise -> - deserializedProject.deserialize(state, atom.deserializers) - .catch (e) -> err = e - - runs -> - expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths()) - expect(err.missingProjectPaths).toEqual ['/directory/that/does/not/exist'] - - it "does not deserialize paths that are now files", -> - childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child') - fs.mkdirSync(childPath) - - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - atom.project.setPaths([childPath]) - state = atom.project.serialize() - - fs.rmdirSync(childPath) - fs.writeFileSync(childPath, 'surprise!\n') - - err = null - waitsForPromise -> - deserializedProject.deserialize(state, atom.deserializers) - .catch (e) -> err = e - - runs -> - expect(deserializedProject.getPaths()).toEqual([]) - expect(err.missingProjectPaths).toEqual [childPath] - - it "does not include unretained buffers in the serialized state", -> - waitsForPromise -> - atom.project.bufferForPath('a') - - runs -> - expect(atom.project.getBuffers().length).toBe 1 - - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 0 - - it "listens for destroyed events on deserialized buffers and removes them when they are destroyed", -> - waitsForPromise -> - atom.workspace.open('a') - - runs -> - expect(atom.project.getBuffers().length).toBe 1 - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 1 - deserializedProject.getBuffers()[0].destroy() - expect(deserializedProject.getBuffers().length).toBe 0 - - it "does not deserialize buffers when their path is now a directory", -> - pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') - - waitsForPromise -> - atom.workspace.open(pathToOpen) - - runs -> - expect(atom.project.getBuffers().length).toBe 1 - fs.mkdirSync(pathToOpen) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 0 - - it "does not deserialize buffers when their path is inaccessible", -> - return if process.platform is 'win32' # chmod not supported on win32 - pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') - fs.writeFileSync(pathToOpen, '') - - waitsForPromise -> - atom.workspace.open(pathToOpen) - - runs -> - expect(atom.project.getBuffers().length).toBe 1 - fs.chmodSync(pathToOpen, '000') - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 0 - - it "does not deserialize buffers with their path is no longer present", -> - pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') - fs.writeFileSync(pathToOpen, '') - - waitsForPromise -> - atom.workspace.open(pathToOpen) - - runs -> - expect(atom.project.getBuffers().length).toBe 1 - fs.unlinkSync(pathToOpen) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 0 - - it "deserializes buffers that have never been saved before", -> - pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') - - waitsForPromise -> - atom.workspace.open(pathToOpen) - - runs -> - atom.workspace.getActiveTextEditor().setText('unsaved\n') - expect(atom.project.getBuffers().length).toBe 1 - - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> - deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(deserializedProject.getBuffers().length).toBe 1 - expect(deserializedProject.getBuffers()[0].getPath()).toBe pathToOpen - expect(deserializedProject.getBuffers()[0].getText()).toBe 'unsaved\n' - - it "serializes marker layers and history only if Atom is quitting", -> - waitsForPromise -> atom.workspace.open('a') - - bufferA = null - layerA = null - markerA = null - - runs -> - bufferA = atom.project.getBuffers()[0] - layerA = bufferA.addMarkerLayer(persistent: true) - markerA = layerA.markPosition([0, 3]) - bufferA.append('!') - notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})) - - runs -> - expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() - expect(notQuittingProject.getBuffers()[0].undo()).toBe(false) - quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - - waitsForPromise -> quittingProject.deserialize(atom.project.serialize({isUnloading: true})) - - runs -> - expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined() - expect(quittingProject.getBuffers()[0].undo()).toBe(true) - - describe "when an editor is saved and the project has no path", -> - it "sets the project's path to the saved file's parent directory", -> - tempFile = temp.openSync().path - atom.project.setPaths([]) - expect(atom.project.getPaths()[0]).toBeUndefined() - editor = null - - waitsForPromise -> - atom.workspace.open().then (o) -> editor = o - - waitsForPromise -> - editor.saveAs(tempFile) - - runs -> - expect(atom.project.getPaths()[0]).toBe path.dirname(tempFile) - - describe "before and after saving a buffer", -> - [buffer] = [] - beforeEach -> - waitsForPromise -> - atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then (o) -> - buffer = o - buffer.retain() - - afterEach -> - buffer.release() - - it "emits save events on the main process", -> - spyOn(atom.project.applicationDelegate, 'emitDidSavePath') - spyOn(atom.project.applicationDelegate, 'emitWillSavePath') - - waitsForPromise -> buffer.save() - - runs -> - expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1) - expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath()) - expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1) - expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath()) - - describe "when a watch error is thrown from the TextBuffer", -> - editor = null - beforeEach -> - waitsForPromise -> - atom.workspace.open(require.resolve('./fixtures/dir/a')).then (o) -> editor = o - - it "creates a warning notification", -> - atom.notifications.onDidAddNotification noteSpy = jasmine.createSpy() - - error = new Error('SomeError') - error.eventType = 'resurrect' - editor.buffer.emitter.emit 'will-throw-watch-error', - handle: jasmine.createSpy() - error: error - - expect(noteSpy).toHaveBeenCalled() - - notification = noteSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getDetail()).toBe 'SomeError' - expect(notification.getMessage()).toContain '`resurrect`' - expect(notification.getMessage()).toContain path.join('fixtures', 'dir', 'a') - - describe "when a custom repository-provider service is provided", -> - [fakeRepositoryProvider, fakeRepository] = [] - - beforeEach -> - fakeRepository = {destroy: -> null} - fakeRepositoryProvider = { - repositoryForDirectory: (directory) -> Promise.resolve(fakeRepository) - repositoryForDirectorySync: (directory) -> fakeRepository - } - - it "uses it to create repositories for any directories that need one", -> - projectPath = temp.mkdirSync('atom-project') - atom.project.setPaths([projectPath]) - expect(atom.project.getRepositories()).toEqual [null] - - atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider) - waitsFor -> atom.project.repositoryProviders.length > 1 - runs -> atom.project.getRepositories()[0] is fakeRepository - - it "does not create any new repositories if every directory has a repository", -> - repositories = atom.project.getRepositories() - expect(repositories.length).toEqual 1 - expect(repositories[0]).toBeTruthy() - - atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider) - waitsFor -> atom.project.repositoryProviders.length > 1 - runs -> expect(atom.project.getRepositories()).toBe repositories - - it "stops using it to create repositories when the service is removed", -> - atom.project.setPaths([]) - - disposable = atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider) - waitsFor -> atom.project.repositoryProviders.length > 1 - runs -> - disposable.dispose() - atom.project.addPath(temp.mkdirSync('atom-project')) - expect(atom.project.getRepositories()).toEqual [null] - - describe "when a custom directory-provider service is provided", -> - class DummyDirectory - constructor: (@path) -> - getPath: -> @path - getFile: -> {existsSync: -> false} - getSubdirectory: -> {existsSync: -> false} - isRoot: -> true - existsSync: -> @path.endsWith('does-exist') - contains: (filePath) -> filePath.startsWith(@path) - - serviceDisposable = null - - beforeEach -> - serviceDisposable = atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", { - directoryForURISync: (uri) -> - if uri.startsWith("ssh://") - new DummyDirectory(uri) - else - null - }) - - waitsFor -> - atom.project.directoryProviders.length > 0 - - it "uses the provider's custom directories for any paths that it handles", -> - localPath = temp.mkdirSync('local-path') - remotePath = "ssh://foreign-directory:8080/does-exist" - - atom.project.setPaths([localPath, remotePath]) - - directories = atom.project.getDirectories() - expect(directories[0].getPath()).toBe localPath - expect(directories[0] instanceof Directory).toBe true - expect(directories[1].getPath()).toBe remotePath - expect(directories[1] instanceof DummyDirectory).toBe true - - # It does not add new remote paths that do not exist - nonExistentRemotePath = "ssh://another-directory:8080/does-not-exist" - atom.project.addPath(nonExistentRemotePath) - expect(atom.project.getDirectories().length).toBe 2 - - # It adds new remote paths if their directories exist. - newRemotePath = "ssh://another-directory:8080/does-exist" - atom.project.addPath(newRemotePath) - directories = atom.project.getDirectories() - expect(directories[2].getPath()).toBe newRemotePath - expect(directories[2] instanceof DummyDirectory).toBe true - - it "stops using the provider when the service is removed", -> - serviceDisposable.dispose() - atom.project.setPaths(["ssh://foreign-directory:8080/does-exist"]) - expect(atom.project.getDirectories().length).toBe(0) - - describe ".open(path)", -> - [absolutePath, newBufferHandler] = [] - - beforeEach -> - absolutePath = require.resolve('./fixtures/dir/a') - newBufferHandler = jasmine.createSpy('newBufferHandler') - atom.project.onDidAddBuffer(newBufferHandler) - - describe "when given an absolute path that isn't currently open", -> - it "returns a new edit session for the given path and emits 'buffer-created'", -> - editor = null - waitsForPromise -> - atom.workspace.open(absolutePath).then (o) -> editor = o - - runs -> - expect(editor.buffer.getPath()).toBe absolutePath - expect(newBufferHandler).toHaveBeenCalledWith editor.buffer - - describe "when given a relative path that isn't currently opened", -> - it "returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", -> - editor = null - waitsForPromise -> - atom.workspace.open(absolutePath).then (o) -> editor = o - - runs -> - expect(editor.buffer.getPath()).toBe absolutePath - expect(newBufferHandler).toHaveBeenCalledWith editor.buffer - - describe "when passed the path to a buffer that is currently opened", -> - it "returns a new edit session containing currently opened buffer", -> - editor = null - - waitsForPromise -> - atom.workspace.open(absolutePath).then (o) -> editor = o - - runs -> - newBufferHandler.reset() - - waitsForPromise -> - atom.workspace.open(absolutePath).then ({buffer}) -> - expect(buffer).toBe editor.buffer - - waitsForPromise -> - atom.workspace.open('a').then ({buffer}) -> - expect(buffer).toBe editor.buffer - expect(newBufferHandler).not.toHaveBeenCalled() - - describe "when not passed a path", -> - it "returns a new edit session and emits 'buffer-created'", -> - editor = null - waitsForPromise -> - atom.workspace.open().then (o) -> editor = o - - runs -> - expect(editor.buffer.getPath()).toBeUndefined() - expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) - - describe ".bufferForPath(path)", -> - buffer = null - - beforeEach -> - waitsForPromise -> - atom.project.bufferForPath("a").then (o) -> - buffer = o - buffer.retain() - - afterEach -> - buffer.release() - - describe "when opening a previously opened path", -> - it "does not create a new buffer", -> - waitsForPromise -> - atom.project.bufferForPath("a").then (anotherBuffer) -> - expect(anotherBuffer).toBe buffer - - waitsForPromise -> - atom.project.bufferForPath("b").then (anotherBuffer) -> - expect(anotherBuffer).not.toBe buffer - - waitsForPromise -> - Promise.all([ - atom.project.bufferForPath('c'), - atom.project.bufferForPath('c') - ]).then ([buffer1, buffer2]) -> - expect(buffer1).toBe(buffer2) - - it "retries loading the buffer if it previously failed", -> - waitsForPromise shouldReject: true, -> - spyOn(TextBuffer, 'load').andCallFake -> - Promise.reject(new Error('Could not open file')) - atom.project.bufferForPath('b') - - waitsForPromise shouldReject: false, -> - TextBuffer.load.andCallThrough() - atom.project.bufferForPath('b') - - it "creates a new buffer if the previous buffer was destroyed", -> - buffer.release() - - waitsForPromise -> - atom.project.bufferForPath("b").then (anotherBuffer) -> - expect(anotherBuffer).not.toBe buffer - - describe ".repositoryForDirectory(directory)", -> - it "resolves to null when the directory does not have a repository", -> - waitsForPromise -> - directory = new Directory("/tmp") - atom.project.repositoryForDirectory(directory).then (result) -> - expect(result).toBeNull() - expect(atom.project.repositoryProviders.length).toBeGreaterThan 0 - expect(atom.project.repositoryPromisesByPath.size).toBe 0 - - it "resolves to a GitRepository and is cached when the given directory is a Git repo", -> - waitsForPromise -> - directory = new Directory(path.join(__dirname, '..')) - promise = atom.project.repositoryForDirectory(directory) - promise.then (result) -> - expect(result).toBeInstanceOf GitRepository - dirPath = directory.getRealPathSync() - expect(result.getPath()).toBe path.join(dirPath, '.git') - - # Verify that the result is cached. - expect(atom.project.repositoryForDirectory(directory)).toBe(promise) - - it "creates a new repository if a previous one with the same directory had been destroyed", -> - repository = null - directory = new Directory(path.join(__dirname, '..')) - - waitsForPromise -> - atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo - - runs -> - expect(repository.isDestroyed()).toBe(false) - repository.destroy() - expect(repository.isDestroyed()).toBe(true) - - waitsForPromise -> - atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo - - runs -> - expect(repository.isDestroyed()).toBe(false) - - describe ".setPaths(paths, options)", -> - describe "when path is a file", -> - it "sets its path to the file's parent directory and updates the root directory", -> - filePath = require.resolve('./fixtures/dir/a') - atom.project.setPaths([filePath]) - expect(atom.project.getPaths()[0]).toEqual path.dirname(filePath) - expect(atom.project.getDirectories()[0].path).toEqual path.dirname(filePath) - - describe "when path is a directory", -> - it "assigns the directories and repositories", -> - directory1 = temp.mkdirSync("non-git-repo") - directory2 = temp.mkdirSync("git-repo1") - directory3 = temp.mkdirSync("git-repo2") - - gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git')) - fs.copySync(gitDirPath, path.join(directory2, ".git")) - fs.copySync(gitDirPath, path.join(directory3, ".git")) - - atom.project.setPaths([directory1, directory2, directory3]) - - [repo1, repo2, repo3] = atom.project.getRepositories() - expect(repo1).toBeNull() - expect(repo2.getShortHead()).toBe "master" - expect(repo2.getPath()).toBe fs.realpathSync(path.join(directory2, ".git")) - expect(repo3.getShortHead()).toBe "master" - expect(repo3.getPath()).toBe fs.realpathSync(path.join(directory3, ".git")) - - it "calls callbacks registered with ::onDidChangePaths", -> - onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') - atom.project.onDidChangePaths(onDidChangePathsSpy) - - paths = [ temp.mkdirSync("dir1"), temp.mkdirSync("dir2") ] - atom.project.setPaths(paths) - - expect(onDidChangePathsSpy.callCount).toBe 1 - expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths) - - it "optionally throws an error with any paths that did not exist", -> - paths = [temp.mkdirSync("exists0"), "/doesnt-exists/0", temp.mkdirSync("exists1"), "/doesnt-exists/1"] - - try - atom.project.setPaths paths, mustExist: true - expect('no exception thrown').toBeUndefined() - catch e - expect(e.missingProjectPaths).toEqual [paths[1], paths[3]] - - expect(atom.project.getPaths()).toEqual [paths[0], paths[2]] - - describe "when no paths are given", -> - it "clears its path", -> - atom.project.setPaths([]) - expect(atom.project.getPaths()).toEqual [] - expect(atom.project.getDirectories()).toEqual [] - - it "normalizes the path to remove consecutive slashes, ., and .. segments", -> - atom.project.setPaths(["#{require.resolve('./fixtures/dir/a')}#{path.sep}b#{path.sep}#{path.sep}.."]) - expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a')) - expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a')) - - describe ".addPath(path, options)", -> - it "calls callbacks registered with ::onDidChangePaths", -> - onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') - atom.project.onDidChangePaths(onDidChangePathsSpy) - - [oldPath] = atom.project.getPaths() - - newPath = temp.mkdirSync("dir") - atom.project.addPath(newPath) - - expect(onDidChangePathsSpy.callCount).toBe 1 - expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath]) - - it "doesn't add redundant paths", -> - onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') - atom.project.onDidChangePaths(onDidChangePathsSpy) - [oldPath] = atom.project.getPaths() - - # Doesn't re-add an existing root directory - atom.project.addPath(oldPath) - expect(atom.project.getPaths()).toEqual([oldPath]) - expect(onDidChangePathsSpy).not.toHaveBeenCalled() - - # Doesn't add an entry for a file-path within an existing root directory - atom.project.addPath(path.join(oldPath, 'some-file.txt')) - expect(atom.project.getPaths()).toEqual([oldPath]) - expect(onDidChangePathsSpy).not.toHaveBeenCalled() - - # Does add an entry for a directory within an existing directory - newPath = path.join(oldPath, "a-dir") - atom.project.addPath(newPath) - expect(atom.project.getPaths()).toEqual([oldPath, newPath]) - expect(onDidChangePathsSpy).toHaveBeenCalled() - - it "doesn't add non-existent directories", -> - previousPaths = atom.project.getPaths() - atom.project.addPath('/this-definitely/does-not-exist') - expect(atom.project.getPaths()).toEqual(previousPaths) - - it "optionally throws on non-existent directories", -> - expect -> - atom.project.addPath '/this-definitely/does-not-exist', mustExist: true - .toThrow() - - describe ".removePath(path)", -> - onDidChangePathsSpy = null - - beforeEach -> - onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener') - atom.project.onDidChangePaths(onDidChangePathsSpy) - - it "removes the directory and repository for the path", -> - result = atom.project.removePath(atom.project.getPaths()[0]) - expect(atom.project.getDirectories()).toEqual([]) - expect(atom.project.getRepositories()).toEqual([]) - expect(atom.project.getPaths()).toEqual([]) - expect(result).toBe true - expect(onDidChangePathsSpy).toHaveBeenCalled() - - it "does nothing if the path is not one of the project's root paths", -> - originalPaths = atom.project.getPaths() - result = atom.project.removePath(originalPaths[0] + "xyz") - expect(result).toBe false - expect(atom.project.getPaths()).toEqual(originalPaths) - expect(onDidChangePathsSpy).not.toHaveBeenCalled() - - it "doesn't destroy the repository if it is shared by another root directory", -> - atom.project.setPaths([__dirname, path.join(__dirname, "..", "src")]) - atom.project.removePath(__dirname) - expect(atom.project.getPaths()).toEqual([path.join(__dirname, "..", "src")]) - expect(atom.project.getRepositories()[0].isSubmodule("src")).toBe false - - it "removes a path that is represented as a URI", -> - atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", { - directoryForURISync: (uri) -> - { - getPath: -> uri - getSubdirectory: -> {} - isRoot: -> true - existsSync: -> true - off: -> - } - }) - - ftpURI = "ftp://example.com/some/folder" - - atom.project.setPaths([ftpURI]) - expect(atom.project.getPaths()).toEqual [ftpURI] - - atom.project.removePath(ftpURI) - expect(atom.project.getPaths()).toEqual [] - - describe ".onDidChangeFiles()", -> - sub = [] - events = [] - checkCallback = -> - - beforeEach -> - sub = atom.project.onDidChangeFiles (incoming) -> - events.push incoming... - checkCallback() - - afterEach -> - sub.dispose() - - waitForEvents = (paths) -> - remaining = new Set(fs.realpathSync(p) for p in paths) - new Promise (resolve, reject) -> - checkCallback = -> - remaining.delete(event.path) for event in events - resolve() if remaining.size is 0 - - expire = -> - checkCallback = -> - console.error "Paths not seen:", Array.from(remaining) - reject(new Error('Expired before all expected events were delivered.')) - - checkCallback() - setTimeout expire, 2000 - - it "reports filesystem changes within project paths", -> - dirOne = temp.mkdirSync('atom-spec-project-one') - fileOne = path.join(dirOne, 'file-one.txt') - fileTwo = path.join(dirOne, 'file-two.txt') - dirTwo = temp.mkdirSync('atom-spec-project-two') - fileThree = path.join(dirTwo, 'file-three.txt') - - # Ensure that all preexisting watchers are stopped - waitsForPromise -> stopAllWatchers() - - runs -> atom.project.setPaths([dirOne]) - waitsForPromise -> atom.project.getWatcherPromise dirOne - - runs -> - expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual undefined - - fs.writeFileSync fileThree, "three\n" - fs.writeFileSync fileTwo, "two\n" - fs.writeFileSync fileOne, "one\n" - - waitsForPromise -> waitForEvents [fileOne, fileTwo] - - runs -> - expect(events.some (event) -> event.path is fileThree).toBeFalsy() - - describe ".onDidAddBuffer()", -> - it "invokes the callback with added text buffers", -> - buffers = [] - added = [] - - waitsForPromise -> - atom.project.buildBuffer(require.resolve('./fixtures/dir/a')) - .then (o) -> buffers.push(o) - - runs -> - expect(buffers.length).toBe 1 - atom.project.onDidAddBuffer (buffer) -> added.push(buffer) - - waitsForPromise -> - atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) - .then (o) -> buffers.push(o) - - runs -> - expect(buffers.length).toBe 2 - expect(added).toEqual [buffers[1]] - - describe ".observeBuffers()", -> - it "invokes the observer with current and future text buffers", -> - buffers = [] - observed = [] - - waitsForPromise -> - atom.project.buildBuffer(require.resolve('./fixtures/dir/a')) - .then (o) -> buffers.push(o) - - waitsForPromise -> - atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) - .then (o) -> buffers.push(o) - - runs -> - expect(buffers.length).toBe 2 - atom.project.observeBuffers (buffer) -> observed.push(buffer) - expect(observed).toEqual buffers - - waitsForPromise -> - atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) - .then (o) -> buffers.push(o) - - runs -> - expect(observed.length).toBe 3 - expect(buffers.length).toBe 3 - expect(observed).toEqual buffers - - describe ".relativize(path)", -> - it "returns the path, relative to whichever root directory it is inside of", -> - atom.project.addPath(temp.mkdirSync("another-path")) - - rootPath = atom.project.getPaths()[0] - childPath = path.join(rootPath, "some", "child", "directory") - expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory") - - rootPath = atom.project.getPaths()[1] - childPath = path.join(rootPath, "some", "child", "directory") - expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory") - - it "returns the given path if it is not in any of the root directories", -> - randomPath = path.join("some", "random", "path") - expect(atom.project.relativize(randomPath)).toBe randomPath - - describe ".relativizePath(path)", -> - it "returns the root path that contains the given path, and the path relativized to that root path", -> - atom.project.addPath(temp.mkdirSync("another-path")) - - rootPath = atom.project.getPaths()[0] - childPath = path.join(rootPath, "some", "child", "directory") - expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")] - - rootPath = atom.project.getPaths()[1] - childPath = path.join(rootPath, "some", "child", "directory") - expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")] - - describe "when the given path isn't inside of any of the project's path", -> - it "returns null for the root path, and the given path unchanged", -> - randomPath = path.join("some", "random", "path") - expect(atom.project.relativizePath(randomPath)).toEqual [null, randomPath] - - describe "when the given path is a URL", -> - it "returns null for the root path, and the given path unchanged", -> - url = "http://the-path" - expect(atom.project.relativizePath(url)).toEqual [null, url] - - describe "when the given path is inside more than one root folder", -> - it "uses the root folder that is closest to the given path", -> - atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir')) - - inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt') - - expect(atom.project.getDirectories()[0].contains(inputPath)).toBe true - expect(atom.project.getDirectories()[1].contains(inputPath)).toBe true - expect(atom.project.relativizePath(inputPath)).toEqual [ - atom.project.getPaths()[1], - path.join('somewhere', 'something.txt') - ] - - describe ".contains(path)", -> - it "returns whether or not the given path is in one of the root directories", -> - rootPath = atom.project.getPaths()[0] - childPath = path.join(rootPath, "some", "child", "directory") - expect(atom.project.contains(childPath)).toBe true - - randomPath = path.join("some", "random", "path") - expect(atom.project.contains(randomPath)).toBe false - - describe ".resolvePath(uri)", -> - it "normalizes disk drive letter in passed path on #win32", -> - expect(atom.project.resolvePath("d:\\file.txt")).toEqual "D:\\file.txt" diff --git a/spec/project-spec.js b/spec/project-spec.js new file mode 100644 index 000000000..63c065fa6 --- /dev/null +++ b/spec/project-spec.js @@ -0,0 +1,927 @@ +const temp = require('temp').track() +const TextBuffer = require('text-buffer') +const Project = require('../src/project') +const fs = require('fs-plus') +const path = require('path') +const {Directory} = require('pathwatcher') +const {stopAllWatchers} = require('../src/path-watcher') +const GitRepository = require('../src/git-repository') + +describe('Project', () => { + beforeEach(() => { + const directory = atom.project.getDirectories()[0] + const paths = directory ? [directory.resolve('dir')] : [null] + atom.project.setPaths(paths) + + // Wait for project's service consumers to be asynchronously added + waits(1) + }) + + describe('serialization', () => { + let deserializedProject = null + let notQuittingProject = null + let quittingProject = null + + afterEach(() => { + if (deserializedProject != null) { + deserializedProject.destroy() + } + if (notQuittingProject != null) { + notQuittingProject.destroy() + } + if (quittingProject != null) { + quittingProject.destroy() + } + }) + + it("does not deserialize paths to directories that don't exist", () => { + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + const state = atom.project.serialize() + state.paths.push('/directory/that/does/not/exist') + + let err = null + waitsForPromise(() => + deserializedProject.deserialize(state, atom.deserializers) + .catch(e => { err = e }) + ) + + runs(() => { + expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths()) + expect(err.missingProjectPaths).toEqual(['/directory/that/does/not/exist']) + }) + }) + + it('does not deserialize paths that are now files', () => { + const childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child') + fs.mkdirSync(childPath) + + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + atom.project.setPaths([childPath]) + const state = atom.project.serialize() + + fs.rmdirSync(childPath) + fs.writeFileSync(childPath, 'surprise!\n') + + let err = null + waitsForPromise(() => + deserializedProject.deserialize(state, atom.deserializers) + .catch(e => { err = e }) + ) + + runs(() => { + expect(deserializedProject.getPaths()).toEqual([]) + expect(err.missingProjectPaths).toEqual([childPath]) + }) + }) + + it('does not include unretained buffers in the serialized state', () => { + waitsForPromise(() => atom.project.bufferForPath('a')) + + runs(() => { + expect(atom.project.getBuffers().length).toBe(1) + + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => expect(deserializedProject.getBuffers().length).toBe(0)) + }) + + it('listens for destroyed events on deserialized buffers and removes them when they are destroyed', () => { + waitsForPromise(() => atom.workspace.open('a')) + + runs(() => { + expect(atom.project.getBuffers().length).toBe(1) + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => { + expect(deserializedProject.getBuffers().length).toBe(1) + deserializedProject.getBuffers()[0].destroy() + expect(deserializedProject.getBuffers().length).toBe(0) + }) + }) + + it('does not deserialize buffers when their path is now a directory', () => { + const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + + waitsForPromise(() => atom.workspace.open(pathToOpen)) + + runs(() => { + expect(atom.project.getBuffers().length).toBe(1) + fs.mkdirSync(pathToOpen) + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => expect(deserializedProject.getBuffers().length).toBe(0)) + }) + + it('does not deserialize buffers when their path is inaccessible', () => { + if (process.platform === 'win32') { return } // chmod not supported on win32 + const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + fs.writeFileSync(pathToOpen, '') + + waitsForPromise(() => atom.workspace.open(pathToOpen)) + + runs(() => { + expect(atom.project.getBuffers().length).toBe(1) + fs.chmodSync(pathToOpen, '000') + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => expect(deserializedProject.getBuffers().length).toBe(0)) + }) + + it('does not deserialize buffers with their path is no longer present', () => { + const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + fs.writeFileSync(pathToOpen, '') + + waitsForPromise(() => atom.workspace.open(pathToOpen)) + + runs(() => { + expect(atom.project.getBuffers().length).toBe(1) + fs.unlinkSync(pathToOpen) + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => expect(deserializedProject.getBuffers().length).toBe(0)) + }) + + it('deserializes buffers that have never been saved before', () => { + const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + + waitsForPromise(() => atom.workspace.open(pathToOpen)) + + runs(() => { + atom.workspace.getActiveTextEditor().setText('unsaved\n') + expect(atom.project.getBuffers().length).toBe(1) + + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => { + expect(deserializedProject.getBuffers().length).toBe(1) + expect(deserializedProject.getBuffers()[0].getPath()).toBe(pathToOpen) + expect(deserializedProject.getBuffers()[0].getText()).toBe('unsaved\n') + }) + }) + + it('serializes marker layers and history only if Atom is quitting', () => { + waitsForPromise(() => atom.workspace.open('a')) + + let bufferA = null + let layerA = null + let markerA = null + + runs(() => { + bufferA = atom.project.getBuffers()[0] + layerA = bufferA.addMarkerLayer({persistent: true}) + markerA = layerA.markPosition([0, 3]) + bufferA.append('!') + notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => notQuittingProject.deserialize(atom.project.serialize({isUnloading: false}))) + + runs(() => { + expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).toBeUndefined() + expect(notQuittingProject.getBuffers()[0].undo()).toBe(false) + quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + }) + + waitsForPromise(() => quittingProject.deserialize(atom.project.serialize({isUnloading: true}))) + + runs(() => { + expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).not.toBeUndefined() + expect(quittingProject.getBuffers()[0].undo()).toBe(true) + }) + }) + }) + + describe('when an editor is saved and the project has no path', () => + it("sets the project's path to the saved file's parent directory", () => { + const tempFile = temp.openSync().path + atom.project.setPaths([]) + expect(atom.project.getPaths()[0]).toBeUndefined() + let editor = null + + waitsForPromise(() => atom.workspace.open().then(o => { editor = o })) + + waitsForPromise(() => editor.saveAs(tempFile)) + + runs(() => expect(atom.project.getPaths()[0]).toBe(path.dirname(tempFile))) + }) + ) + + describe('before and after saving a buffer', () => { + let buffer + beforeEach(() => + waitsForPromise(() => + atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then((o) => { + buffer = o + buffer.retain() + }) + ) + ) + + afterEach(() => buffer.release()) + + it('emits save events on the main process', () => { + spyOn(atom.project.applicationDelegate, 'emitDidSavePath') + spyOn(atom.project.applicationDelegate, 'emitWillSavePath') + + waitsForPromise(() => buffer.save()) + + runs(() => { + expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1) + expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath()) + expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1) + expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath()) + }) + }) + }) + + describe('when a watch error is thrown from the TextBuffer', () => { + let editor = null + beforeEach(() => + waitsForPromise(() => atom.workspace.open(require.resolve('./fixtures/dir/a')).then(o => { editor = o })) + ) + + it('creates a warning notification', () => { + let noteSpy + atom.notifications.onDidAddNotification(noteSpy = jasmine.createSpy()) + + const error = new Error('SomeError') + error.eventType = 'resurrect' + editor.buffer.emitter.emit('will-throw-watch-error', { + handle: jasmine.createSpy(), + error + } + ) + + expect(noteSpy).toHaveBeenCalled() + + const notification = noteSpy.mostRecentCall.args[0] + expect(notification.getType()).toBe('warning') + expect(notification.getDetail()).toBe('SomeError') + expect(notification.getMessage()).toContain('`resurrect`') + expect(notification.getMessage()).toContain(path.join('fixtures', 'dir', 'a')) + }) + }) + + describe('when a custom repository-provider service is provided', () => { + let fakeRepositoryProvider, fakeRepository + + beforeEach(() => { + fakeRepository = {destroy () { return null }} + fakeRepositoryProvider = { + repositoryForDirectory (directory) { return Promise.resolve(fakeRepository) }, + repositoryForDirectorySync (directory) { return fakeRepository } + } + }) + + it('uses it to create repositories for any directories that need one', () => { + const projectPath = temp.mkdirSync('atom-project') + atom.project.setPaths([projectPath]) + expect(atom.project.getRepositories()).toEqual([null]) + + atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider) + waitsFor(() => atom.project.repositoryProviders.length > 1) + runs(() => atom.project.getRepositories()[0] === fakeRepository) + }) + + it('does not create any new repositories if every directory has a repository', () => { + const repositories = atom.project.getRepositories() + expect(repositories.length).toEqual(1) + expect(repositories[0]).toBeTruthy() + + atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider) + waitsFor(() => atom.project.repositoryProviders.length > 1) + runs(() => expect(atom.project.getRepositories()).toBe(repositories)) + }) + + it('stops using it to create repositories when the service is removed', () => { + atom.project.setPaths([]) + + const disposable = atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider) + waitsFor(() => atom.project.repositoryProviders.length > 1) + runs(() => { + disposable.dispose() + atom.project.addPath(temp.mkdirSync('atom-project')) + expect(atom.project.getRepositories()).toEqual([null]) + }) + }) + }) + + describe('when a custom directory-provider service is provided', () => { + class DummyDirectory { + constructor (aPath) { + this.path = aPath + } + getPath () { return this.path } + getFile () { return {existsSync () { return false }} } + getSubdirectory () { return {existsSync () { return false }} } + isRoot () { return true } + existsSync () { return this.path.endsWith('does-exist') } + contains (filePath) { return filePath.startsWith(this.path) } + } + + let serviceDisposable = null + + beforeEach(() => { + serviceDisposable = atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', { + directoryForURISync (uri) { + if (uri.startsWith('ssh://')) { + return new DummyDirectory(uri) + } else { + return null + } + } + }) + + waitsFor(() => atom.project.directoryProviders.length > 0) + }) + + it("uses the provider's custom directories for any paths that it handles", () => { + const localPath = temp.mkdirSync('local-path') + const remotePath = 'ssh://foreign-directory:8080/does-exist' + + atom.project.setPaths([localPath, remotePath]) + + let directories = atom.project.getDirectories() + expect(directories[0].getPath()).toBe(localPath) + expect(directories[0] instanceof Directory).toBe(true) + expect(directories[1].getPath()).toBe(remotePath) + expect(directories[1] instanceof DummyDirectory).toBe(true) + + // It does not add new remote paths that do not exist + const nonExistentRemotePath = 'ssh://another-directory:8080/does-not-exist' + atom.project.addPath(nonExistentRemotePath) + expect(atom.project.getDirectories().length).toBe(2) + + // It adds new remote paths if their directories exist. + const newRemotePath = 'ssh://another-directory:8080/does-exist' + atom.project.addPath(newRemotePath) + directories = atom.project.getDirectories() + expect(directories[2].getPath()).toBe(newRemotePath) + expect(directories[2] instanceof DummyDirectory).toBe(true) + }) + + it('stops using the provider when the service is removed', () => { + serviceDisposable.dispose() + atom.project.setPaths(['ssh://foreign-directory:8080/does-exist']) + expect(atom.project.getDirectories().length).toBe(0) + }) + }) + + describe('.open(path)', () => { + let absolutePath, newBufferHandler + + beforeEach(() => { + absolutePath = require.resolve('./fixtures/dir/a') + newBufferHandler = jasmine.createSpy('newBufferHandler') + atom.project.onDidAddBuffer(newBufferHandler) + }) + + describe("when given an absolute path that isn't currently open", () => + it("returns a new edit session for the given path and emits 'buffer-created'", () => { + let editor = null + waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o })) + + runs(() => { + expect(editor.buffer.getPath()).toBe(absolutePath) + expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) + }) + }) + ) + + describe("when given a relative path that isn't currently opened", () => + it("returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", () => { + let editor = null + waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o })) + + runs(() => { + expect(editor.buffer.getPath()).toBe(absolutePath) + expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) + }) + }) + ) + + describe('when passed the path to a buffer that is currently opened', () => + it('returns a new edit session containing currently opened buffer', () => { + let editor = null + + waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o })) + + runs(() => newBufferHandler.reset()) + + waitsForPromise(() => + atom.workspace.open(absolutePath).then(({buffer}) => expect(buffer).toBe(editor.buffer)) + ) + + waitsForPromise(() => + atom.workspace.open('a').then(({buffer}) => { + expect(buffer).toBe(editor.buffer) + expect(newBufferHandler).not.toHaveBeenCalled() + }) + ) + }) + ) + + describe('when not passed a path', () => + it("returns a new edit session and emits 'buffer-created'", () => { + let editor = null + waitsForPromise(() => atom.workspace.open().then(o => { editor = o })) + + runs(() => { + expect(editor.buffer.getPath()).toBeUndefined() + expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) + }) + }) + ) + }) + + describe('.bufferForPath(path)', () => { + let buffer = null + + beforeEach(() => + waitsForPromise(() => + atom.project.bufferForPath('a').then((o) => { + buffer = o + buffer.retain() + }) + ) + ) + + afterEach(() => buffer.release()) + + describe('when opening a previously opened path', () => { + it('does not create a new buffer', () => { + waitsForPromise(() => + atom.project.bufferForPath('a').then(anotherBuffer => expect(anotherBuffer).toBe(buffer)) + ) + + waitsForPromise(() => + atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer)) + ) + + waitsForPromise(() => + Promise.all([ + atom.project.bufferForPath('c'), + atom.project.bufferForPath('c') + ]).then(([buffer1, buffer2]) => { + expect(buffer1).toBe(buffer2) + }) + ) + }) + + it('retries loading the buffer if it previously failed', () => { + waitsForPromise({shouldReject: true}, () => { + spyOn(TextBuffer, 'load').andCallFake(() => Promise.reject(new Error('Could not open file'))) + return atom.project.bufferForPath('b') + }) + + waitsForPromise({shouldReject: false}, () => { + TextBuffer.load.andCallThrough() + return atom.project.bufferForPath('b') + }) + }) + + it('creates a new buffer if the previous buffer was destroyed', () => { + buffer.release() + + waitsForPromise(() => + atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer)) + ) + }) + }) + }) + + describe('.repositoryForDirectory(directory)', () => { + it('resolves to null when the directory does not have a repository', () => + waitsForPromise(() => { + const directory = new Directory('/tmp') + return atom.project.repositoryForDirectory(directory).then((result) => { + expect(result).toBeNull() + expect(atom.project.repositoryProviders.length).toBeGreaterThan(0) + expect(atom.project.repositoryPromisesByPath.size).toBe(0) + }) + }) + ) + + it('resolves to a GitRepository and is cached when the given directory is a Git repo', () => + waitsForPromise(() => { + const directory = new Directory(path.join(__dirname, '..')) + const promise = atom.project.repositoryForDirectory(directory) + return promise.then((result) => { + expect(result).toBeInstanceOf(GitRepository) + const dirPath = directory.getRealPathSync() + expect(result.getPath()).toBe(path.join(dirPath, '.git')) + + // Verify that the result is cached. + expect(atom.project.repositoryForDirectory(directory)).toBe(promise) + }) + }) + ) + + it('creates a new repository if a previous one with the same directory had been destroyed', () => { + let repository = null + const directory = new Directory(path.join(__dirname, '..')) + + waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo })) + + runs(() => { + expect(repository.isDestroyed()).toBe(false) + repository.destroy() + expect(repository.isDestroyed()).toBe(true) + }) + + waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo })) + + runs(() => expect(repository.isDestroyed()).toBe(false)) + }) + }) + + describe('.setPaths(paths, options)', () => { + describe('when path is a file', () => + it("sets its path to the file's parent directory and updates the root directory", () => { + const filePath = require.resolve('./fixtures/dir/a') + atom.project.setPaths([filePath]) + expect(atom.project.getPaths()[0]).toEqual(path.dirname(filePath)) + expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(filePath)) + }) + ) + + describe('when path is a directory', () => { + it('assigns the directories and repositories', () => { + const directory1 = temp.mkdirSync('non-git-repo') + const directory2 = temp.mkdirSync('git-repo1') + const directory3 = temp.mkdirSync('git-repo2') + + const gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git')) + fs.copySync(gitDirPath, path.join(directory2, '.git')) + fs.copySync(gitDirPath, path.join(directory3, '.git')) + + atom.project.setPaths([directory1, directory2, directory3]) + + const [repo1, repo2, repo3] = atom.project.getRepositories() + expect(repo1).toBeNull() + expect(repo2.getShortHead()).toBe('master') + expect(repo2.getPath()).toBe(fs.realpathSync(path.join(directory2, '.git'))) + expect(repo3.getShortHead()).toBe('master') + expect(repo3.getPath()).toBe(fs.realpathSync(path.join(directory3, '.git'))) + }) + + it('calls callbacks registered with ::onDidChangePaths', () => { + const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + + const paths = [ temp.mkdirSync('dir1'), temp.mkdirSync('dir2') ] + atom.project.setPaths(paths) + + expect(onDidChangePathsSpy.callCount).toBe(1) + expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths) + }) + + it('optionally throws an error with any paths that did not exist', () => { + const paths = [temp.mkdirSync('exists0'), '/doesnt-exists/0', temp.mkdirSync('exists1'), '/doesnt-exists/1'] + + try { + atom.project.setPaths(paths, {mustExist: true}) + expect('no exception thrown').toBeUndefined() + } catch (e) { + expect(e.missingProjectPaths).toEqual([paths[1], paths[3]]) + } + + expect(atom.project.getPaths()).toEqual([paths[0], paths[2]]) + }) + }) + + describe('when no paths are given', () => + it('clears its path', () => { + atom.project.setPaths([]) + expect(atom.project.getPaths()).toEqual([]) + expect(atom.project.getDirectories()).toEqual([]) + }) + ) + + it('normalizes the path to remove consecutive slashes, ., and .. segments', () => { + atom.project.setPaths([`${require.resolve('./fixtures/dir/a')}${path.sep}b${path.sep}${path.sep}..`]) + expect(atom.project.getPaths()[0]).toEqual(path.dirname(require.resolve('./fixtures/dir/a'))) + expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(require.resolve('./fixtures/dir/a'))) + }) + }) + + describe('.addPath(path, options)', () => { + it('calls callbacks registered with ::onDidChangePaths', () => { + const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + + const [oldPath] = atom.project.getPaths() + + const newPath = temp.mkdirSync('dir') + atom.project.addPath(newPath) + + expect(onDidChangePathsSpy.callCount).toBe(1) + expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath]) + }) + + it("doesn't add redundant paths", () => { + const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') + atom.project.onDidChangePaths(onDidChangePathsSpy) + const [oldPath] = atom.project.getPaths() + + // Doesn't re-add an existing root directory + atom.project.addPath(oldPath) + expect(atom.project.getPaths()).toEqual([oldPath]) + expect(onDidChangePathsSpy).not.toHaveBeenCalled() + + // Doesn't add an entry for a file-path within an existing root directory + atom.project.addPath(path.join(oldPath, 'some-file.txt')) + expect(atom.project.getPaths()).toEqual([oldPath]) + expect(onDidChangePathsSpy).not.toHaveBeenCalled() + + // Does add an entry for a directory within an existing directory + const newPath = path.join(oldPath, 'a-dir') + atom.project.addPath(newPath) + expect(atom.project.getPaths()).toEqual([oldPath, newPath]) + expect(onDidChangePathsSpy).toHaveBeenCalled() + }) + + it("doesn't add non-existent directories", () => { + const previousPaths = atom.project.getPaths() + atom.project.addPath('/this-definitely/does-not-exist') + expect(atom.project.getPaths()).toEqual(previousPaths) + }) + + it('optionally throws on non-existent directories', () => + expect(() => atom.project.addPath('/this-definitely/does-not-exist', {mustExist: true})).toThrow() + ) + }) + + describe('.removePath(path)', () => { + let onDidChangePathsSpy = null + + beforeEach(() => { + onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener') + atom.project.onDidChangePaths(onDidChangePathsSpy) + }) + + it('removes the directory and repository for the path', () => { + const result = atom.project.removePath(atom.project.getPaths()[0]) + expect(atom.project.getDirectories()).toEqual([]) + expect(atom.project.getRepositories()).toEqual([]) + expect(atom.project.getPaths()).toEqual([]) + expect(result).toBe(true) + expect(onDidChangePathsSpy).toHaveBeenCalled() + }) + + it("does nothing if the path is not one of the project's root paths", () => { + const originalPaths = atom.project.getPaths() + const result = atom.project.removePath(originalPaths[0] + 'xyz') + expect(result).toBe(false) + expect(atom.project.getPaths()).toEqual(originalPaths) + expect(onDidChangePathsSpy).not.toHaveBeenCalled() + }) + + it("doesn't destroy the repository if it is shared by another root directory", () => { + atom.project.setPaths([__dirname, path.join(__dirname, '..', 'src')]) + atom.project.removePath(__dirname) + expect(atom.project.getPaths()).toEqual([path.join(__dirname, '..', 'src')]) + expect(atom.project.getRepositories()[0].isSubmodule('src')).toBe(false) + }) + + it('removes a path that is represented as a URI', () => { + atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', { + directoryForURISync (uri) { + return { + getPath () { return uri }, + getSubdirectory () { return {} }, + isRoot () { return true }, + existsSync () { return true }, + off () {} + } + } + }) + + const ftpURI = 'ftp://example.com/some/folder' + + atom.project.setPaths([ftpURI]) + expect(atom.project.getPaths()).toEqual([ftpURI]) + + atom.project.removePath(ftpURI) + expect(atom.project.getPaths()).toEqual([]) + }) + }) + + describe('.onDidChangeFiles()', () => { + let sub = [] + const events = [] + let checkCallback = () => {} + + beforeEach(() => { + sub = atom.project.onDidChangeFiles((incoming) => { + events.push(...incoming) + checkCallback() + }) + }) + + afterEach(() => sub.dispose()) + + const waitForEvents = (paths) => { + const remaining = new Set(paths.map((p) => fs.realpathSync(p))) + return new Promise((resolve, reject) => { + checkCallback = () => { + for (let event of events) { remaining.delete(event.path) } + if (remaining.size === 0) { resolve() } + } + + const expire = () => { + checkCallback = () => {} + console.error('Paths not seen:', remaining) + reject(new Error('Expired before all expected events were delivered.')) + } + + checkCallback() + setTimeout(expire, 2000) + }) + } + + it('reports filesystem changes within project paths', () => { + const dirOne = temp.mkdirSync('atom-spec-project-one') + const fileOne = path.join(dirOne, 'file-one.txt') + const fileTwo = path.join(dirOne, 'file-two.txt') + const dirTwo = temp.mkdirSync('atom-spec-project-two') + const fileThree = path.join(dirTwo, 'file-three.txt') + + // Ensure that all preexisting watchers are stopped + waitsForPromise(() => stopAllWatchers()) + + runs(() => atom.project.setPaths([dirOne])) + waitsForPromise(() => atom.project.getWatcherPromise(dirOne)) + + runs(() => { + expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual(undefined) + + fs.writeFileSync(fileThree, 'three\n') + fs.writeFileSync(fileTwo, 'two\n') + fs.writeFileSync(fileOne, 'one\n') + }) + + waitsForPromise(() => waitForEvents([fileOne, fileTwo])) + + runs(() => expect(events.some(event => event.path === fileThree)).toBeFalsy()) + }) + }) + + describe('.onDidAddBuffer()', () => + it('invokes the callback with added text buffers', () => { + const buffers = [] + const added = [] + + waitsForPromise(() => + atom.project.buildBuffer(require.resolve('./fixtures/dir/a')) + .then(o => buffers.push(o)) + ) + + runs(() => { + expect(buffers.length).toBe(1) + atom.project.onDidAddBuffer(buffer => added.push(buffer)) + }) + + waitsForPromise(() => + atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) + .then(o => buffers.push(o)) + ) + + runs(() => { + expect(buffers.length).toBe(2) + expect(added).toEqual([buffers[1]]) + }) + }) +) + + describe('.observeBuffers()', () => + it('invokes the observer with current and future text buffers', () => { + const buffers = [] + const observed = [] + + waitsForPromise(() => + atom.project.buildBuffer(require.resolve('./fixtures/dir/a')) + .then(o => buffers.push(o)) + ) + + waitsForPromise(() => + atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) + .then(o => buffers.push(o)) + ) + + runs(() => { + expect(buffers.length).toBe(2) + atom.project.observeBuffers(buffer => observed.push(buffer)) + expect(observed).toEqual(buffers) + }) + + waitsForPromise(() => + atom.project.buildBuffer(require.resolve('./fixtures/dir/b')) + .then(o => buffers.push(o)) + ) + + runs(() => { + expect(observed.length).toBe(3) + expect(buffers.length).toBe(3) + expect(observed).toEqual(buffers) + }) + }) + ) + + describe('.relativize(path)', () => { + it('returns the path, relative to whichever root directory it is inside of', () => { + atom.project.addPath(temp.mkdirSync('another-path')) + + let rootPath = atom.project.getPaths()[0] + let childPath = path.join(rootPath, 'some', 'child', 'directory') + expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory')) + + rootPath = atom.project.getPaths()[1] + childPath = path.join(rootPath, 'some', 'child', 'directory') + expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory')) + }) + + it('returns the given path if it is not in any of the root directories', () => { + const randomPath = path.join('some', 'random', 'path') + expect(atom.project.relativize(randomPath)).toBe(randomPath) + }) + }) + + describe('.relativizePath(path)', () => { + it('returns the root path that contains the given path, and the path relativized to that root path', () => { + atom.project.addPath(temp.mkdirSync('another-path')) + + let rootPath = atom.project.getPaths()[0] + let childPath = path.join(rootPath, 'some', 'child', 'directory') + expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')]) + + rootPath = atom.project.getPaths()[1] + childPath = path.join(rootPath, 'some', 'child', 'directory') + expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')]) + }) + + describe("when the given path isn't inside of any of the project's path", () => + it('returns null for the root path, and the given path unchanged', () => { + const randomPath = path.join('some', 'random', 'path') + expect(atom.project.relativizePath(randomPath)).toEqual([null, randomPath]) + }) + ) + + describe('when the given path is a URL', () => + it('returns null for the root path, and the given path unchanged', () => { + const url = 'http://the-path' + expect(atom.project.relativizePath(url)).toEqual([null, url]) + }) + ) + + describe('when the given path is inside more than one root folder', () => + it('uses the root folder that is closest to the given path', () => { + atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir')) + + const inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt') + + expect(atom.project.getDirectories()[0].contains(inputPath)).toBe(true) + expect(atom.project.getDirectories()[1].contains(inputPath)).toBe(true) + expect(atom.project.relativizePath(inputPath)).toEqual([ + atom.project.getPaths()[1], + path.join('somewhere', 'something.txt') + ]) + }) + ) + }) + + describe('.contains(path)', () => + it('returns whether or not the given path is in one of the root directories', () => { + const rootPath = atom.project.getPaths()[0] + const childPath = path.join(rootPath, 'some', 'child', 'directory') + expect(atom.project.contains(childPath)).toBe(true) + + const randomPath = path.join('some', 'random', 'path') + expect(atom.project.contains(randomPath)).toBe(false) + }) + ) + + describe('.resolvePath(uri)', () => + it('normalizes disk drive letter in passed path on #win32', () => { + expect(atom.project.resolvePath('d:\\file.txt')).toEqual('D:\\file.txt') + }) + ) +}) diff --git a/spec/selection-spec.coffee b/spec/selection-spec.coffee deleted file mode 100644 index cb070310a..000000000 --- a/spec/selection-spec.coffee +++ /dev/null @@ -1,123 +0,0 @@ -TextEditor = require '../src/text-editor' - -describe "Selection", -> - [buffer, editor, selection] = [] - - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - editor = new TextEditor({buffer: buffer, tabLength: 2}) - selection = editor.getLastSelection() - - afterEach -> - buffer.destroy() - - describe ".deleteSelectedText()", -> - describe "when nothing is selected", -> - it "deletes nothing", -> - selection.setBufferRange [[0, 3], [0, 3]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - describe "when one line is selected", -> - it "deletes selected text and clears the selection", -> - selection.setBufferRange [[0, 4], [0, 14]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "var = function () {" - - endOfLine = buffer.lineForRow(0).length - selection.setBufferRange [[0, 0], [0, endOfLine]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "" - - expect(selection.isEmpty()).toBeTruthy() - - describe "when multiple lines are selected", -> - it "deletes selected text and clears the selection", -> - selection.setBufferRange [[0, 1], [2, 39]] - selection.deleteSelectedText() - expect(buffer.lineForRow(0)).toBe "v;" - expect(selection.isEmpty()).toBeTruthy() - - describe "when the cursor precedes the tail", -> - it "deletes selected text and clears the selection", -> - selection.cursor.setScreenPosition [0, 13] - selection.selectToScreenPosition [0, 4] - - selection.delete() - expect(buffer.lineForRow(0)).toBe "var = function () {" - expect(selection.isEmpty()).toBeTruthy() - - describe ".isReversed()", -> - it "returns true if the cursor precedes the tail", -> - selection.cursor.setScreenPosition([0, 20]) - selection.selectToScreenPosition([0, 10]) - expect(selection.isReversed()).toBeTruthy() - - selection.selectToScreenPosition([0, 25]) - expect(selection.isReversed()).toBeFalsy() - - describe ".selectLine(row)", -> - describe "when passed a row", -> - it "selects the specified row", -> - selection.setBufferRange([[2, 4], [3, 4]]) - selection.selectLine(5) - expect(selection.getBufferRange()).toEqual [[5, 0], [6, 0]] - - describe "when not passed a row", -> - it "selects all rows spanned by the selection", -> - selection.setBufferRange([[2, 4], [3, 4]]) - selection.selectLine() - expect(selection.getBufferRange()).toEqual [[2, 0], [4, 0]] - - describe "when only the selection's tail is moved (regression)", -> - it "notifies ::onDidChangeRange observers", -> - selection.setBufferRange([[2, 0], [2, 10]], reversed: true) - changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') - selection.onDidChangeRange changeScreenRangeHandler - - buffer.insert([2, 5], 'abc') - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the selection is destroyed", -> - it "destroys its marker", -> - selection.setBufferRange([[2, 0], [2, 10]]) - marker = selection.marker - selection.destroy() - expect(marker.isDestroyed()).toBeTruthy() - - describe ".insertText(text, options)", -> - it "allows pasting white space only lines when autoIndent is enabled", -> - selection.setBufferRange [[0, 0], [0, 0]] - selection.insertText(" \n \n\n", autoIndent: true) - expect(buffer.lineForRow(0)).toBe " " - expect(buffer.lineForRow(1)).toBe " " - expect(buffer.lineForRow(2)).toBe "" - - it "auto-indents if only a newline is inserted", -> - selection.setBufferRange [[2, 0], [3, 0]] - selection.insertText("\n", autoIndent: true) - expect(buffer.lineForRow(2)).toBe " " - - it "auto-indents if only a carriage return + newline is inserted", -> - selection.setBufferRange [[2, 0], [3, 0]] - selection.insertText("\r\n", autoIndent: true) - expect(buffer.lineForRow(2)).toBe " " - - describe ".fold()", -> - it "folds the buffer range spanned by the selection", -> - selection.setBufferRange([[0, 3], [1, 6]]) - selection.fold() - - expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]]) - expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]]) - expect(editor.lineTextForScreenRow(0)).toBe "var#{editor.displayLayer.foldCharacter}sort = function(items) {" - expect(editor.isFoldedAtBufferRow(0)).toBe(true) - - it "doesn't create a fold when the selection is empty", -> - selection.setBufferRange([[0, 3], [0, 3]]) - selection.fold() - - expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]]) - expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]]) - expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {" - expect(editor.isFoldedAtBufferRow(0)).toBe(false) diff --git a/spec/selection-spec.js b/spec/selection-spec.js new file mode 100644 index 000000000..cb586da26 --- /dev/null +++ b/spec/selection-spec.js @@ -0,0 +1,157 @@ +const TextEditor = require('../src/text-editor') + +describe('Selection', () => { + let buffer, editor, selection + + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + editor = new TextEditor({buffer, tabLength: 2}) + selection = editor.getLastSelection() + }) + + afterEach(() => buffer.destroy()) + + describe('.deleteSelectedText()', () => { + describe('when nothing is selected', () => { + it('deletes nothing', () => { + selection.setBufferRange([[0, 3], [0, 3]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + }) + + describe('when one line is selected', () => { + it('deletes selected text and clears the selection', () => { + selection.setBufferRange([[0, 4], [0, 14]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('var = function () {') + + const endOfLine = buffer.lineForRow(0).length + selection.setBufferRange([[0, 0], [0, endOfLine]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('') + + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + describe('when multiple lines are selected', () => { + it('deletes selected text and clears the selection', () => { + selection.setBufferRange([[0, 1], [2, 39]]) + selection.deleteSelectedText() + expect(buffer.lineForRow(0)).toBe('v;') + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + describe('when the cursor precedes the tail', () => { + it('deletes selected text and clears the selection', () => { + selection.cursor.setScreenPosition([0, 13]) + selection.selectToScreenPosition([0, 4]) + + selection.delete() + expect(buffer.lineForRow(0)).toBe('var = function () {') + expect(selection.isEmpty()).toBeTruthy() + }) + }) + }) + + describe('.isReversed()', () => { + it('returns true if the cursor precedes the tail', () => { + selection.cursor.setScreenPosition([0, 20]) + selection.selectToScreenPosition([0, 10]) + expect(selection.isReversed()).toBeTruthy() + + selection.selectToScreenPosition([0, 25]) + expect(selection.isReversed()).toBeFalsy() + }) + }) + + describe('.selectLine(row)', () => { + describe('when passed a row', () => { + it('selects the specified row', () => { + selection.setBufferRange([[2, 4], [3, 4]]) + selection.selectLine(5) + expect(selection.getBufferRange()).toEqual([[5, 0], [6, 0]]) + }) + }) + + describe('when not passed a row', () => { + it('selects all rows spanned by the selection', () => { + selection.setBufferRange([[2, 4], [3, 4]]) + selection.selectLine() + expect(selection.getBufferRange()).toEqual([[2, 0], [4, 0]]) + }) + }) + }) + + describe("when only the selection's tail is moved (regression)", () => { + it('notifies ::onDidChangeRange observers', () => { + selection.setBufferRange([[2, 0], [2, 10]], {reversed: true}) + const changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') + selection.onDidChangeRange(changeScreenRangeHandler) + + buffer.insert([2, 5], 'abc') + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the selection is destroyed', () => { + it('destroys its marker', () => { + selection.setBufferRange([[2, 0], [2, 10]]) + const { marker } = selection + selection.destroy() + expect(marker.isDestroyed()).toBeTruthy() + }) + }) + + describe('.insertText(text, options)', () => { + it('allows pasting white space only lines when autoIndent is enabled', () => { + selection.setBufferRange([[0, 0], [0, 0]]) + selection.insertText(' \n \n\n', {autoIndent: true}) + expect(buffer.lineForRow(0)).toBe(' ') + expect(buffer.lineForRow(1)).toBe(' ') + expect(buffer.lineForRow(2)).toBe('') + }) + + it('auto-indents if only a newline is inserted', () => { + selection.setBufferRange([[2, 0], [3, 0]]) + selection.insertText('\n', {autoIndent: true}) + expect(buffer.lineForRow(2)).toBe(' ') + }) + + it('auto-indents if only a carriage return + newline is inserted', () => { + selection.setBufferRange([[2, 0], [3, 0]]) + selection.insertText('\r\n', {autoIndent: true}) + expect(buffer.lineForRow(2)).toBe(' ') + }) + + it('does not adjust the indent of trailing lines if preserveTrailingLineIndentation is true', () => { + selection.setBufferRange([[5, 0], [5, 0]]) + selection.insertText(' foo\n bar\n', {preserveTrailingLineIndentation: true, indentBasis: 1}) + expect(buffer.lineForRow(6)).toBe(' bar') + }) + }) + + describe('.fold()', () => { + it('folds the buffer range spanned by the selection', () => { + selection.setBufferRange([[0, 3], [1, 6]]) + selection.fold() + + expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]]) + expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]]) + expect(editor.lineTextForScreenRow(0)).toBe(`var${editor.displayLayer.foldCharacter}sort = function(items) {`) + expect(editor.isFoldedAtBufferRow(0)).toBe(true) + }) + + it("doesn't create a fold when the selection is empty", () => { + selection.setBufferRange([[0, 3], [0, 3]]) + selection.fold() + + expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]]) + expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]]) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + expect(editor.isFoldedAtBufferRow(0)).toBe(false) + }) + }) +}) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index c20bfc827..7621f9cae 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -58,7 +58,7 @@ if specPackagePath = FindParentDir.sync(testPaths[0], 'package.json') if specDirectory = FindParentDir.sync(testPaths[0], 'fixtures') specProjectPath = path.join(specDirectory, 'fixtures') else - specProjectPath = path.join(__dirname, 'fixtures') + specProjectPath = require('os').tmpdir() beforeEach -> atom.project.setPaths([specProjectPath]) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 41d770212..992785d6e 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -1896,6 +1896,8 @@ describe('TextEditorComponent', () => { const decoration = editor.decorateMarker(marker, {type: 'overlay', item: overlayElement, class: 'a'}) await component.getNextUpdatePromise() + const overlayComponent = component.overlayComponents.values().next().value + const overlayWrapper = overlayElement.parentElement expect(overlayWrapper.classList.contains('a')).toBe(true) expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) @@ -1926,12 +1928,12 @@ describe('TextEditorComponent', () => { await setScrollTop(component, 20) expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) overlayElement.style.height = 60 + 'px' - await component.getNextUpdatePromise() + await overlayComponent.getNextUpdatePromise() expect(overlayWrapper.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 4)) // Does not flip the overlay vertically if it would overflow the top of the window overlayElement.style.height = 80 + 'px' - await component.getNextUpdatePromise() + await overlayComponent.getNextUpdatePromise() expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5)) // Can update overlay wrapper class @@ -4426,11 +4428,14 @@ describe('TextEditorComponent', () => { const {component, editor} = buildComponent() let dragging = false - component.handleMouseDragUntilMouseUp({ - didDrag: (event) => { dragging = true }, - didStopDragging: () => { dragging = false } - }) + function startDragging () { + component.handleMouseDragUntilMouseUp({ + didDrag: (event) => { dragging = true }, + didStopDragging: () => { dragging = false } + }) + } + startDragging() window.dispatchEvent(new MouseEvent('mousemove')) await getNextAnimationFramePromise() expect(dragging).toBe(true) @@ -4446,6 +4451,17 @@ describe('TextEditorComponent', () => { window.dispatchEvent(new MouseEvent('mousemove')) await getNextAnimationFramePromise() expect(dragging).toBe(false) + + // Pressing a modifier key does not terminate dragging, (to ensure we can add new selections with the mouse) + startDragging() + window.dispatchEvent(new MouseEvent('mousemove')) + await getNextAnimationFramePromise() + expect(dragging).toBe(true) + component.didKeydown({key: 'Control'}) + component.didKeydown({key: 'Alt'}) + component.didKeydown({key: 'Shift'}) + component.didKeydown({key: 'Meta'}) + expect(dragging).toBe(true) }) function getNextAnimationFramePromise () { diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee deleted file mode 100644 index 53011fdcc..000000000 --- a/spec/text-editor-spec.coffee +++ /dev/null @@ -1,5959 +0,0 @@ -path = require 'path' -clipboard = require '../src/safe-clipboard' -TextEditor = require '../src/text-editor' -TextBuffer = require 'text-buffer' - -describe "TextEditor", -> - [buffer, editor, lineLengths] = [] - - convertToHardTabs = (buffer) -> - buffer.setText(buffer.getText().replace(/[ ]{2}/g, "\t")) - - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js', {autoIndent: false}).then (o) -> editor = o - - runs -> - buffer = editor.buffer - editor.update({autoIndent: false}) - lineLengths = buffer.getLines().map (line) -> line.length - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - describe "when the editor is deserialized", -> - it "restores selections and folds based on markers in the buffer", -> - editor.setSelectedBufferRange([[1, 2], [3, 4]]) - editor.addSelectionForBufferRange([[5, 6], [7, 5]], reversed: true) - editor.foldBufferRow(4) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - - waitsForPromise -> - TextBuffer.deserialize(editor.buffer.serialize()).then (buffer2) -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> buffer2} - }) - - expect(editor2.id).toBe editor.id - expect(editor2.getBuffer().getPath()).toBe editor.getBuffer().getPath() - expect(editor2.getSelectedBufferRanges()).toEqual [[[1, 2], [3, 4]], [[5, 6], [7, 5]]] - expect(editor2.getSelections()[1].isReversed()).toBeTruthy() - expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() - editor2.destroy() - - it "restores the editor's layout configuration", -> - editor.update({ - softTabs: true - atomicSoftTabs: false - tabLength: 12 - softWrapped: true - softWrapAtPreferredLineLength: true - softWrapHangingIndentLength: 8 - invisibles: {space: 'S'} - showInvisibles: true - editorWidthInChars: 120 - }) - - # Force buffer and display layer to be deserialized as well, rather than - # reusing the same buffer instance - waitsForPromise -> - TextBuffer.deserialize(editor.buffer.serialize()).then (buffer2) -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> buffer2} - }) - - expect(editor2.getSoftTabs()).toBe(editor.getSoftTabs()) - expect(editor2.hasAtomicSoftTabs()).toBe(editor.hasAtomicSoftTabs()) - expect(editor2.getTabLength()).toBe(editor.getTabLength()) - expect(editor2.getSoftWrapColumn()).toBe(editor.getSoftWrapColumn()) - expect(editor2.getSoftWrapHangingIndentLength()).toBe(editor.getSoftWrapHangingIndentLength()) - expect(editor2.getInvisibles()).toEqual(editor.getInvisibles()) - expect(editor2.getEditorWidthInChars()).toBe(editor.getEditorWidthInChars()) - expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength()) - expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn()) - - it "ignores buffers with retired IDs", -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> null} - }) - - expect(editor2).toBeNull() - - describe "when the editor is constructed with the largeFileMode option set to true", -> - it "loads the editor but doesn't tokenize", -> - editor = null - - waitsForPromise -> - atom.workspace.openTextFile('sample.js', largeFileMode: true).then (o) -> editor = o - - runs -> - buffer = editor.getBuffer() - expect(editor.lineTextForScreenRow(0)).toBe buffer.lineForRow(0) - expect(editor.tokensForScreenRow(0).length).toBe 1 - expect(editor.tokensForScreenRow(1).length).toBe 2 # soft tab - expect(editor.lineTextForScreenRow(12)).toBe buffer.lineForRow(12) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - editor.insertText('hey"') - expect(editor.tokensForScreenRow(0).length).toBe 1 - expect(editor.tokensForScreenRow(1).length).toBe 2 # soft tab - - describe ".copy()", -> - it "returns a different editor with the same initial state", -> - expect(editor.getAutoHeight()).toBeFalsy() - expect(editor.getAutoWidth()).toBeFalsy() - expect(editor.getShowCursorOnSelection()).toBeTruthy() - - element = editor.getElement() - element.setHeight(100) - element.setWidth(100) - jasmine.attachToDOM(element) - - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRange([[1, 2], [3, 4]]) - editor.addSelectionForBufferRange([[5, 6], [7, 8]], reversed: true) - editor.setScrollTopRow(3) - expect(editor.getScrollTopRow()).toBe(3) - editor.setScrollLeftColumn(4) - expect(editor.getScrollLeftColumn()).toBe(4) - editor.foldBufferRow(4) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - - editor2 = editor.copy() - element2 = editor2.getElement() - element2.setHeight(100) - element2.setWidth(100) - jasmine.attachToDOM(element2) - expect(editor2.id).not.toBe editor.id - expect(editor2.getSelectedBufferRanges()).toEqual editor.getSelectedBufferRanges() - expect(editor2.getSelections()[1].isReversed()).toBeTruthy() - expect(editor2.getScrollTopRow()).toBe(3) - expect(editor2.getScrollLeftColumn()).toBe(4) - expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor2.getAutoWidth()).toBe(false) - expect(editor2.getAutoHeight()).toBe(false) - expect(editor2.getShowCursorOnSelection()).toBeFalsy() - - # editor2 can now diverge from its origin edit session - editor2.getLastSelection().setBufferRange([[2, 1], [4, 3]]) - expect(editor2.getSelectedBufferRanges()).not.toEqual editor.getSelectedBufferRanges() - editor2.unfoldBufferRow(4) - expect(editor2.isFoldedAtBufferRow(4)).not.toBe editor.isFoldedAtBufferRow(4) - - describe ".update()", -> - it "updates the editor with the supplied config parameters", -> - element = editor.element # force element initialization - element.setUpdatedSynchronously(false) - editor.update({showInvisibles: true}) - editor.onDidChange(changeSpy = jasmine.createSpy('onDidChange')) - - returnedPromise = editor.update({ - tabLength: 6, softTabs: false, softWrapped: true, editorWidthInChars: 40, - showInvisibles: false, mini: false, lineNumberGutterVisible: false, scrollPastEnd: true, - autoHeight: false, maxScreenLineLength: 1000 - }) - - expect(returnedPromise).toBe(element.component.getNextUpdatePromise()) - expect(changeSpy.callCount).toBe(1) - expect(editor.getTabLength()).toBe(6) - expect(editor.getSoftTabs()).toBe(false) - expect(editor.isSoftWrapped()).toBe(true) - expect(editor.getEditorWidthInChars()).toBe(40) - expect(editor.getInvisibles()).toEqual({}) - expect(editor.isMini()).toBe(false) - expect(editor.isLineNumberGutterVisible()).toBe(false) - expect(editor.getScrollPastEnd()).toBe(true) - expect(editor.getAutoHeight()).toBe(false) - - describe "title", -> - describe ".getTitle()", -> - it "uses the basename of the buffer's path as its title, or 'untitled' if the path is undefined", -> - expect(editor.getTitle()).toBe 'sample.js' - buffer.setPath(undefined) - expect(editor.getTitle()).toBe 'untitled' - - describe ".getLongTitle()", -> - it "returns file name when there is no opened file with identical name", -> - expect(editor.getLongTitle()).toBe 'sample.js' - buffer.setPath(undefined) - expect(editor.getLongTitle()).toBe 'untitled' - - it "returns ' — ' when opened files have identical file names", -> - editor1 = null - editor2 = null - waitsForPromise -> - atom.workspace.open(path.join('sample-theme-1', 'readme')).then (o) -> - editor1 = o - atom.workspace.open(path.join('sample-theme-2', 'readme')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "readme \u2014 sample-theme-1" - expect(editor2.getLongTitle()).toBe "readme \u2014 sample-theme-2" - - it "returns ' — ' when opened files have identical file names in subdirectories", -> - editor1 = null - editor2 = null - path1 = path.join('sample-theme-1', 'src', 'js') - path2 = path.join('sample-theme-2', 'src', 'js') - waitsForPromise -> - atom.workspace.open(path.join(path1, 'main.js')).then (o) -> - editor1 = o - atom.workspace.open(path.join(path2, 'main.js')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "main.js \u2014 #{path1}" - expect(editor2.getLongTitle()).toBe "main.js \u2014 #{path2}" - - it "returns ' — ' when opened files have identical file and same parent dir name", -> - editor1 = null - editor2 = null - waitsForPromise -> - atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'main.js')).then (o) -> - editor1 = o - atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'plugin', 'main.js')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "main.js \u2014 js" - expect(editor2.getLongTitle()).toBe "main.js \u2014 " + path.join('js', 'plugin') - - it "notifies ::onDidChangeTitle observers when the underlying buffer path changes", -> - observed = [] - editor.onDidChangeTitle (title) -> observed.push(title) - - buffer.setPath('/foo/bar/baz.txt') - buffer.setPath(undefined) - - expect(observed).toEqual ['baz.txt', 'untitled'] - - describe "path", -> - it "notifies ::onDidChangePath observers when the underlying buffer path changes", -> - observed = [] - editor.onDidChangePath (filePath) -> observed.push(filePath) - - buffer.setPath(__filename) - buffer.setPath(undefined) - - expect(observed).toEqual [__filename, undefined] - - describe "encoding", -> - it "notifies ::onDidChangeEncoding observers when the editor encoding changes", -> - observed = [] - editor.onDidChangeEncoding (encoding) -> observed.push(encoding) - - editor.setEncoding('utf16le') - editor.setEncoding('utf16le') - editor.setEncoding('utf16be') - editor.setEncoding() - editor.setEncoding() - - expect(observed).toEqual ['utf16le', 'utf16be', 'utf8'] - - describe "cursor", -> - describe ".getLastCursor()", -> - it "returns the most recently created cursor", -> - editor.addCursorAtScreenPosition([1, 0]) - lastCursor = editor.addCursorAtScreenPosition([2, 0]) - expect(editor.getLastCursor()).toBe lastCursor - - it "creates a new cursor at (0, 0) if the last cursor has been destroyed", -> - editor.getLastCursor().destroy() - expect(editor.getLastCursor().getBufferPosition()).toEqual([0, 0]) - - describe ".getCursors()", -> - it "creates a new cursor at (0, 0) if the last cursor has been destroyed", -> - editor.getLastCursor().destroy() - expect(editor.getCursors()[0].getBufferPosition()).toEqual([0, 0]) - - describe "when the cursor moves", -> - it "clears a goal column established by vertical movement", -> - editor.setText('b') - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - editor.moveUp() - editor.insertText('a') - editor.moveDown() - expect(editor.getCursorBufferPosition()).toEqual [1, 1] - - it "emits an event with the old position, new position, and the cursor that moved", -> - cursorCallback = jasmine.createSpy('cursor-changed-position') - editorCallback = jasmine.createSpy('editor-changed-cursor-position') - - editor.getLastCursor().onDidChangePosition(cursorCallback) - editor.onDidChangeCursorPosition(editorCallback) - - editor.setCursorBufferPosition([2, 4]) - - expect(editorCallback).toHaveBeenCalled() - expect(cursorCallback).toHaveBeenCalled() - eventObject = editorCallback.mostRecentCall.args[0] - expect(cursorCallback.mostRecentCall.args[0]).toEqual(eventObject) - - expect(eventObject.oldBufferPosition).toEqual [0, 0] - expect(eventObject.oldScreenPosition).toEqual [0, 0] - expect(eventObject.newBufferPosition).toEqual [2, 4] - expect(eventObject.newScreenPosition).toEqual [2, 4] - expect(eventObject.cursor).toBe editor.getLastCursor() - - describe ".setCursorScreenPosition(screenPosition)", -> - it "clears a goal column established by vertical movement", -> - # set a goal column by moving down - editor.setCursorScreenPosition(row: 3, column: lineLengths[3]) - editor.moveDown() - expect(editor.getCursorScreenPosition().column).not.toBe 6 - - # clear the goal column by explicitly setting the cursor position - editor.setCursorScreenPosition([4, 6]) - expect(editor.getCursorScreenPosition().column).toBe 6 - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe 6 - - it "merges multiple cursors", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 1]) - [cursor1, cursor2] = editor.getCursors() - editor.setCursorScreenPosition([4, 7]) - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()).toEqual [cursor1] - expect(editor.getCursorScreenPosition()).toEqual [4, 7] - - describe "when soft-wrap is enabled and code is folded", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - editor.foldBufferRowRange(2, 3) - - it "positions the cursor at the buffer position that corresponds to the given screen position", -> - editor.setCursorScreenPosition([9, 0]) - expect(editor.getCursorBufferPosition()).toEqual [8, 11] - - describe ".moveUp()", -> - it "moves the cursor up", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual [1, 2] - - it "retains the goal column across lines of differing length", -> - expect(lineLengths[6]).toBeGreaterThan(32) - editor.setCursorScreenPosition(row: 6, column: 32) - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[5] - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[4] - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe 32 - - describe "when the cursor is on the first line", -> - it "moves the cursor to the beginning of the line, but retains the goal column", -> - editor.setCursorScreenPosition([0, 4]) - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual([1, 4]) - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[4, 9], [5, 10]]) - - it "moves above the selection", -> - cursor = editor.getLastCursor() - editor.moveUp() - expect(cursor.getBufferPosition()).toEqual [3, 9] - - it "merges cursors when they overlap", -> - editor.addCursorAtScreenPosition([1, 0]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveUp() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - - describe "when the cursor was moved down from the beginning of an indented soft-wrapped line", -> - it "moves to the beginning of the previous line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - editor.setCursorScreenPosition([3, 0]) - editor.moveDown() - editor.moveDown() - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual [4, 4] - - describe ".moveDown()", -> - it "moves the cursor down", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual [3, 2] - - it "retains the goal column across lines of differing length", -> - editor.setCursorScreenPosition(row: 3, column: lineLengths[3]) - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[4] - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[5] - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[3] - - describe "when the cursor is on the last line", -> - it "moves the cursor to the end of line, but retains the goal column when moving back up", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - editor.setCursorScreenPosition(row: lastLineIndex, column: editor.getTabLength()) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual(row: lastLineIndex, column: lastLine.length) - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe editor.getTabLength() - - it "retains a goal column of 0 when moving back up", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - editor.setCursorScreenPosition(row: lastLineIndex, column: 0) - editor.moveDown() - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe 0 - - describe "when the cursor is at the beginning of an indented soft-wrapped line", -> - it "moves to the beginning of the line's continuation on the next screen row", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - editor.setCursorScreenPosition([3, 0]) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual [4, 4] - - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[4, 9], [5, 10]]) - - it "moves below the selection", -> - cursor = editor.getLastCursor() - editor.moveDown() - expect(cursor.getBufferPosition()).toEqual [6, 10] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([12, 2]) - editor.addCursorAtScreenPosition([11, 2]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveDown() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12, 2] - - describe ".moveLeft()", -> - it "moves the cursor by one column to the left", -> - editor.setCursorScreenPosition([1, 8]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [1, 7] - - it "moves the cursor by n columns to the left", -> - editor.setCursorScreenPosition([1, 8]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [1, 4] - - it "moves the cursor by two rows up when the columnCount is longer than an entire line", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveLeft(34) - expect(editor.getCursorScreenPosition()).toEqual [0, 29] - - it "moves the cursor to the beginning columnCount is longer than the position in the buffer", -> - editor.setCursorScreenPosition([1, 0]) - editor.moveLeft(100) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - - describe "when the cursor is in the first column", -> - describe "when there is a previous line", -> - it "wraps to the end of the previous line", -> - editor.setCursorScreenPosition(row: 1, column: 0) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual(row: 0, column: buffer.lineForRow(0).length) - - it "moves the cursor by one row up and n columns to the left", -> - editor.setCursorScreenPosition([1, 0]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [0, 26] - - describe "when the next line is empty", -> - it "wraps to the beginning of the previous line", -> - editor.setCursorScreenPosition([11, 0]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [10, 0] - - describe "when line is wrapped and follow previous line indentation", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - it "wraps to the end of the previous line", -> - editor.setCursorScreenPosition([4, 4]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [3, 46] - - describe "when the cursor is on the first line", -> - it "remains in the same position (0,0)", -> - editor.setCursorScreenPosition(row: 0, column: 0) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual(row: 0, column: 0) - - it "remains in the same position (0,0) when columnCount is specified", -> - editor.setCursorScreenPosition([0, 0]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - - describe "when softTabs is enabled and the cursor is preceded by leading whitespace", -> - it "skips tabLength worth of whitespace at a time", -> - editor.setCursorBufferPosition([5, 6]) - - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [5, 4] - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[5, 22], [5, 27]]) - - it "moves to the left of the selection", -> - cursor = editor.getLastCursor() - editor.moveLeft() - expect(cursor.getBufferPosition()).toEqual [5, 22] - - editor.moveLeft() - expect(cursor.getBufferPosition()).toEqual [5, 21] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 1]) - - [cursor1, cursor2] = editor.getCursors() - editor.moveLeft() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - - describe ".moveRight()", -> - it "moves the cursor by one column to the right", -> - editor.setCursorScreenPosition([3, 3]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [3, 4] - - it "moves the cursor by n columns to the right", -> - editor.setCursorScreenPosition([3, 7]) - editor.moveRight(4) - expect(editor.getCursorScreenPosition()).toEqual [3, 11] - - it "moves the cursor by two rows down when the columnCount is longer than an entire line", -> - editor.setCursorScreenPosition([0, 29]) - editor.moveRight(34) - expect(editor.getCursorScreenPosition()).toEqual [2, 2] - - it "moves the cursor to the end of the buffer when columnCount is longer than the number of characters following the cursor position", -> - editor.setCursorScreenPosition([11, 5]) - editor.moveRight(100) - expect(editor.getCursorScreenPosition()).toEqual [12, 2] - - describe "when the cursor is on the last column of a line", -> - describe "when there is a subsequent line", -> - it "wraps to the beginning of the next line", -> - editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [1, 0] - - it "moves the cursor by one row down and n columns to the right", -> - editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) - editor.moveRight(4) - expect(editor.getCursorScreenPosition()).toEqual [1, 3] - - describe "when the next line is empty", -> - it "wraps to the beginning of the next line", -> - editor.setCursorScreenPosition([9, 4]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [10, 0] - - describe "when the cursor is on the last line", -> - it "remains in the same position", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - lastPosition = {row: lastLineIndex, column: lastLine.length} - editor.setCursorScreenPosition(lastPosition) - editor.moveRight() - - expect(editor.getCursorScreenPosition()).toEqual(lastPosition) - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[5, 22], [5, 27]]) - - it "moves to the left of the selection", -> - cursor = editor.getLastCursor() - editor.moveRight() - expect(cursor.getBufferPosition()).toEqual [5, 27] - - editor.moveRight() - expect(cursor.getBufferPosition()).toEqual [5, 28] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([12, 2]) - editor.addCursorAtScreenPosition([12, 1]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveRight() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12, 2] - - describe ".moveToTop()", -> - it "moves the cursor to the top of the buffer", -> - editor.setCursorScreenPosition [11, 1] - editor.addCursorAtScreenPosition [12, 0] - editor.moveToTop() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".moveToBottom()", -> - it "moves the cursor to the bottom of the buffer", -> - editor.setCursorScreenPosition [0, 0] - editor.addCursorAtScreenPosition [1, 0] - editor.moveToBottom() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [12, 2] - - describe ".moveToBeginningOfScreenLine()", -> - describe "when soft wrap is on", -> - it "moves cursor to the beginning of the screen line", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToBeginningOfScreenLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [1, 0] - - describe "when soft wrap is off", -> - it "moves cursor to the beginning of the line", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - editor.moveToBeginningOfScreenLine() - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 0] - - describe ".moveToEndOfScreenLine()", -> - describe "when soft wrap is on", -> - it "moves cursor to the beginning of the screen line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToEndOfScreenLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [1, 9] - - describe "when soft wrap is off", -> - it "moves cursor to the end of line", -> - editor.setCursorScreenPosition [0, 0] - editor.addCursorAtScreenPosition [1, 0] - editor.moveToEndOfScreenLine() - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 29] - expect(cursor2.getBufferPosition()).toEqual [1, 30] - - describe ".moveToBeginningOfLine()", -> - it "moves cursor to the beginning of the buffer line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToBeginningOfLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [0, 0] - - describe ".moveToEndOfLine()", -> - it "moves cursor to the end of the buffer line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([0, 2]) - editor.moveToEndOfLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [4, 4] - - describe ".moveToFirstCharacterOfLine()", -> - describe "when soft wrap is on", -> - it "moves to the first character of the current screen line or the beginning of the screen line if it's already on the first character", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition [2, 5] - editor.addCursorAtScreenPosition [8, 7] - - editor.moveToFirstCharacterOfLine() - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getScreenPosition()).toEqual [2, 0] - expect(cursor2.getScreenPosition()).toEqual [8, 2] - - editor.moveToFirstCharacterOfLine() - expect(cursor1.getScreenPosition()).toEqual [2, 0] - expect(cursor2.getScreenPosition()).toEqual [8, 2] - - describe "when soft wrap is off", -> - it "moves to the first character of the current line or the beginning of the line if it's already on the first character", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - - editor.moveToFirstCharacterOfLine() - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 2] - - editor.moveToFirstCharacterOfLine() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 0] - - it "moves to the beginning of the line if it only contains whitespace ", -> - editor.setText("first\n \nthird") - editor.setCursorScreenPosition [1, 2] - editor.moveToFirstCharacterOfLine() - cursor = editor.getLastCursor() - expect(cursor.getBufferPosition()).toEqual [1, 0] - - describe "when invisible characters are enabled with soft tabs", -> - it "moves to the first character of the current line without being confused by the invisible characters", -> - editor.update({showInvisibles: true}) - editor.setCursorScreenPosition [1, 7] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - - describe "when invisible characters are enabled with hard tabs", -> - it "moves to the first character of the current line without being confused by the invisible characters", -> - editor.update({showInvisibles: true}) - buffer.setTextInRange([[1, 0], [1, Infinity]], '\t\t\ta', normalizeLineEndings: false) - - editor.setCursorScreenPosition [1, 7] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 3] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - - describe ".moveToBeginningOfWord()", -> - it "moves the cursor to the beginning of the word", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [1, 12] - editor.addCursorAtBufferPosition [3, 0] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToBeginningOfWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [1, 11] - expect(cursor3.getBufferPosition()).toEqual [2, 39] - - it "does not fail at position [0, 0]", -> - editor.setCursorBufferPosition([0, 0]) - editor.moveToBeginningOfWord() - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([11, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "treats lines with only whitespace as a word (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([11, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [9, 2] - - it "works when the current line is blank (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [9, 2] - editor.buffer.setText(buffer.getText().replace(/\r\n/g, "\n")) - - describe ".moveToPreviousWordBoundary()", -> - it "moves the cursor to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 0] - editor.addCursorAtBufferPosition [2, 4] - editor.addCursorAtBufferPosition [3, 14] - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.moveToPreviousWordBoundary() - - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [1, 30] - expect(cursor3.getBufferPosition()).toEqual [2, 0] - expect(cursor4.getBufferPosition()).toEqual [3, 13] - - describe ".moveToNextWordBoundary()", -> - it "moves the cursor to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 40] - editor.addCursorAtBufferPosition [3, 0] - editor.addCursorAtBufferPosition [3, 30] - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.moveToNextWordBoundary() - - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 0] - expect(cursor3.getBufferPosition()).toEqual [3, 4] - expect(cursor4.getBufferPosition()).toEqual [3, 31] - - describe ".moveToEndOfWord()", -> - it "moves the cursor to the end of the word", -> - editor.setCursorBufferPosition [0, 6] - editor.addCursorAtBufferPosition [1, 10] - editor.addCursorAtBufferPosition [2, 40] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToEndOfWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [1, 12] - expect(cursor3.getBufferPosition()).toEqual [3, 7] - - it "does not blow up when there is no next word", -> - editor.setCursorBufferPosition [Infinity, Infinity] - endPosition = editor.getCursorBufferPosition() - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual endPosition - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([9, 4]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "treats lines with only whitespace as a word (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([9, 4]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 8] - - it "works when the current line is blank (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([10, 0]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 8] - - describe ".moveToBeginningOfNextWord()", -> - it "moves the cursor before the first character of the next word", -> - editor.setCursorBufferPosition [0, 6] - editor.addCursorAtBufferPosition [1, 11] - editor.addCursorAtBufferPosition [2, 0] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToBeginningOfNextWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [1, 13] - expect(cursor3.getBufferPosition()).toEqual [2, 4] - - # When the cursor is on whitespace - editor.setText("ab cde- ") - editor.setCursorBufferPosition [0, 2] - cursor = editor.getLastCursor() - editor.moveToBeginningOfNextWord() - - expect(cursor.getBufferPosition()).toEqual [0, 3] - - it "does not blow up when there is no next word", -> - editor.setCursorBufferPosition [Infinity, Infinity] - endPosition = editor.getCursorBufferPosition() - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual endPosition - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([9, 4]) - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 9] - - describe ".moveToPreviousSubwordBoundary", -> - it "does not move the cursor when there is no previous subword boundary", -> - editor.setText('') - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - it "stops at word and underscore boundaries", -> - editor.setText("sub_word \n") - editor.setCursorBufferPosition([0, 9]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 8]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - editor.setText(" word\n") - editor.setCursorBufferPosition([0, 3]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "stops at camelCase boundaries", -> - editor.setText(" getPreviousWord\n") - editor.setCursorBufferPosition([0, 16]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 12]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "skips consecutive non-word characters", -> - editor.setText("e, => \n") - editor.setCursorBufferPosition([0, 6]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "skips consecutive uppercase characters", -> - editor.setText(" AAADF \n") - editor.setCursorBufferPosition([0, 7]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 6]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.setText("ALPhA\n") - editor.setCursorBufferPosition([0, 4]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 2]) - - it "skips consecutive numbers", -> - editor.setText(" 88 \n") - editor.setCursorBufferPosition([0, 4]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "works with multiple cursors", -> - editor.setText("curOp\ncursorOptions\n") - editor.setCursorBufferPosition([0, 8]) - editor.addCursorAtBufferPosition([1, 13]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveToPreviousSubwordBoundary() - - expect(cursor1.getBufferPosition()).toEqual([0, 3]) - expect(cursor2.getBufferPosition()).toEqual([1, 6]) - - it "works with non-English characters", -> - editor.setText("supåTøåst \n") - editor.setCursorBufferPosition([0, 9]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.setText("supaÖast \n") - editor.setCursorBufferPosition([0, 8]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - describe ".moveToNextSubwordBoundary", -> - it "does not move the cursor when there is no next subword boundary", -> - editor.setText('') - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - it "stops at word and underscore boundaries", -> - editor.setText(" sub_word \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 9]) - - editor.setText("word \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - it "stops at camelCase boundaries", -> - editor.setText("getPreviousWord \n") - editor.setCursorBufferPosition([0, 0]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 11]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 15]) - - it "skips consecutive non-word characters", -> - editor.setText(", => \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - it "skips consecutive uppercase characters", -> - editor.setText(" AAADF \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 6]) - - editor.setText("ALPhA\n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 2]) - - it "skips consecutive numbers", -> - editor.setText(" 88 \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - it "works with multiple cursors", -> - editor.setText("curOp\ncursorOptions\n") - editor.setCursorBufferPosition([0, 0]) - editor.addCursorAtBufferPosition([1, 0]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveToNextSubwordBoundary() - expect(cursor1.getBufferPosition()).toEqual([0, 3]) - expect(cursor2.getBufferPosition()).toEqual([1, 6]) - - it "works with non-English characters", -> - editor.setText("supåTøåst \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.setText("supaÖast \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - describe ".moveToBeginningOfNextParagraph()", -> - it "moves the cursor before the first line of the next paragraph", -> - editor.setCursorBufferPosition [0, 6] - editor.foldBufferRow(4) - - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - it "moves the cursor before the first line of the next paragraph (CRLF line endings)", -> - editor.setText(editor.getText().replace(/\n/g, '\r\n')) - - editor.setCursorBufferPosition [0, 6] - editor.foldBufferRow(4) - - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".moveToBeginningOfPreviousParagraph()", -> - it "moves the cursor before the first line of the previous paragraph", -> - editor.setCursorBufferPosition [10, 0] - editor.foldBufferRow(4) - - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - it "moves the cursor before the first line of the previous paragraph (CRLF line endings)", -> - editor.setText(editor.getText().replace(/\n/g, '\r\n')) - - editor.setCursorBufferPosition [10, 0] - editor.foldBufferRow(4) - - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".getCurrentParagraphBufferRange()", -> - it "returns the buffer range of the current paragraph, delimited by blank lines or the beginning / end of the file", -> - buffer.setText """ - I am the first paragraph, - bordered by the beginning of - the file - #{' '} - - I am the second paragraph - with blank lines above and below - me. - - I am the last paragraph, - bordered by the end of the file. - """ - - # in a paragraph - editor.setCursorBufferPosition([1, 7]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[0, 0], [2, 8]] - - editor.setCursorBufferPosition([7, 1]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[5, 0], [7, 3]] - - editor.setCursorBufferPosition([9, 10]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[9, 0], [10, 32]] - - # between paragraphs - editor.setCursorBufferPosition([3, 1]) - expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() - - it 'will limit paragraph range to comments', -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - editor.setText(""" - var quicksort = function () { - /* Single line comment block */ - var sort = function(items) {}; - - /* - A multiline - comment is here - */ - var sort = function(items) {}; - - // A comment - // - // Multiple comment - // lines - var sort = function(items) {}; - // comment line after fn - - var nosort = function(items) { - item; - } - - }; - """) - - paragraphBufferRangeForRow = (row) -> - editor.setCursorBufferPosition([row, 0]) - editor.getLastCursor().getCurrentParagraphBufferRange() - - expect(paragraphBufferRangeForRow(0)).toEqual([[0, 0], [0, 29]]) - expect(paragraphBufferRangeForRow(1)).toEqual([[1, 0], [1, 33]]) - expect(paragraphBufferRangeForRow(2)).toEqual([[2, 0], [2, 32]]) - expect(paragraphBufferRangeForRow(3)).toBeFalsy() - expect(paragraphBufferRangeForRow(4)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(5)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(6)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(7)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(8)).toEqual([[8, 0], [8, 32]]) - expect(paragraphBufferRangeForRow(9)).toBeFalsy() - expect(paragraphBufferRangeForRow(10)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(11)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(12)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(14)).toEqual([[14, 0], [14, 32]]) - expect(paragraphBufferRangeForRow(15)).toEqual([[15, 0], [15, 26]]) - expect(paragraphBufferRangeForRow(18)).toEqual([[17, 0], [19, 3]]) - - describe "getCursorAtScreenPosition(screenPosition)", -> - it "returns the cursor at the given screenPosition", -> - cursor1 = editor.addCursorAtScreenPosition([0, 2]) - cursor2 = editor.getCursorAtScreenPosition(cursor1.getScreenPosition()) - expect(cursor2).toBe cursor1 - - describe "::getCursorScreenPositions()", -> - it "returns the cursor positions in the order they were added", -> - editor.foldBufferRow(4) - cursor1 = editor.addCursorAtBufferPosition([8, 5]) - cursor2 = editor.addCursorAtBufferPosition([3, 5]) - expect(editor.getCursorScreenPositions()).toEqual [[0, 0], [5, 5], [3, 5]] - - describe "::getCursorsOrderedByBufferPosition()", -> - it "returns all cursors ordered by buffer positions", -> - originalCursor = editor.getLastCursor() - cursor1 = editor.addCursorAtBufferPosition([8, 5]) - cursor2 = editor.addCursorAtBufferPosition([4, 5]) - expect(editor.getCursorsOrderedByBufferPosition()).toEqual [originalCursor, cursor2, cursor1] - - describe "addCursorAtScreenPosition(screenPosition)", -> - describe "when a cursor already exists at the position", -> - it "returns the existing cursor", -> - cursor1 = editor.addCursorAtScreenPosition([0, 2]) - cursor2 = editor.addCursorAtScreenPosition([0, 2]) - expect(cursor2).toBe cursor1 - - describe "addCursorAtBufferPosition(bufferPosition)", -> - describe "when a cursor already exists at the position", -> - it "returns the existing cursor", -> - cursor1 = editor.addCursorAtBufferPosition([1, 4]) - cursor2 = editor.addCursorAtBufferPosition([1, 4]) - expect(cursor2.marker).toBe cursor1.marker - - describe '.getCursorScope()', -> - it 'returns the current scope', -> - descriptor = editor.getCursorScope() - expect(descriptor.scopes).toContain('source.js') - - describe "selection", -> - selection = null - - beforeEach -> - selection = editor.getLastSelection() - - describe ".getLastSelection()", -> - it "creates a new selection at (0, 0) if the last selection has been destroyed", -> - editor.getLastSelection().destroy() - expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) - - it "doesn't get stuck in a infinite loop when called from ::onDidAddCursor after the last selection has been destroyed (regression)", -> - callCount = 0 - editor.getLastSelection().destroy() - editor.onDidAddCursor (cursor) -> - callCount++ - editor.getLastSelection() - expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) - expect(callCount).toBe(1) - - describe ".getSelections()", -> - it "creates a new selection at (0, 0) if the last selection has been destroyed", -> - editor.getLastSelection().destroy() - expect(editor.getSelections()[0].getBufferRange()).toEqual([[0, 0], [0, 0]]) - - describe "when the selection range changes", -> - it "emits an event with the old range, new range, and the selection that moved", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - - editor.onDidChangeSelectionRange rangeChangedHandler = jasmine.createSpy() - editor.selectToBufferPosition([6, 2]) - - expect(rangeChangedHandler).toHaveBeenCalled() - eventObject = rangeChangedHandler.mostRecentCall.args[0] - - expect(eventObject.oldBufferRange).toEqual [[3, 0], [4, 5]] - expect(eventObject.oldScreenRange).toEqual [[3, 0], [4, 5]] - expect(eventObject.newBufferRange).toEqual [[3, 0], [6, 2]] - expect(eventObject.newScreenRange).toEqual [[3, 0], [6, 2]] - expect(eventObject.selection).toBe selection - - describe ".selectUp/Down/Left/Right()", -> - it "expands each selection to its cursor's new location", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 14]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 22]] - - editor.selectLeft() - editor.selectLeft() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - editor.selectDown() - expect(selection1.getBufferRange()).toEqual [[0, 9], [1, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [4, 20]] - - editor.selectUp() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - it "merges selections when they intersect when moving down", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]], [[2, 15], [3, 25]]]) - [selection1, selection2, selection3] = editor.getSelections() - - editor.selectDown() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 9], [4, 25]]) - expect(selection1.isReversed()).toBeFalsy() - - it "merges selections when they intersect when moving up", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]]], reversed: true) - [selection1, selection2] = editor.getSelections() - - editor.selectUp() - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 0], [1, 20]]) - expect(selection1.isReversed()).toBeTruthy() - - it "merges selections when they intersect when moving left", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[0, 13], [1, 20]]], reversed: true) - [selection1, selection2] = editor.getSelections() - - editor.selectLeft() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 8], [1, 20]]) - expect(selection1.isReversed()).toBeTruthy() - - it "merges selections when they intersect when moving right", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 14]], [[0, 14], [1, 20]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 9], [1, 21]]) - expect(selection1.isReversed()).toBeFalsy() - - describe "when counts are passed into the selection functions", -> - it "expands each selection to its cursor's new location", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight(2) - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 15]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 23]] - - editor.selectLeft(3) - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - editor.selectDown(3) - expect(selection1.getBufferRange()).toEqual [[0, 9], [3, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [6, 20]] - - editor.selectUp(2) - expect(selection1.getBufferRange()).toEqual [[0, 9], [1, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [4, 20]] - - describe ".selectToBufferPosition(bufferPosition)", -> - it "expands the last selection to the given position", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtBufferPosition([5, 6]) - editor.selectToBufferPosition([6, 2]) - - selections = editor.getSelections() - expect(selections.length).toBe 2 - [selection1, selection2] = selections - expect(selection1.getBufferRange()).toEqual [[3, 0], [4, 5]] - expect(selection2.getBufferRange()).toEqual [[5, 6], [6, 2]] - - describe ".selectToScreenPosition(screenPosition)", -> - it "expands the last selection to the given position", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - selections = editor.getSelections() - expect(selections.length).toBe 2 - [selection1, selection2] = selections - expect(selection1.getScreenRange()).toEqual [[3, 0], [4, 5]] - expect(selection2.getScreenRange()).toEqual [[5, 6], [6, 2]] - - describe "when selecting with an initial screen range", -> - it "switches the direction of the selection when selecting to positions before/after the start of the initial range", -> - editor.setCursorScreenPosition([5, 10]) - editor.selectWordsContainingCursors() - editor.selectToScreenPosition([3, 0]) - expect(editor.getLastSelection().isReversed()).toBe true - editor.selectToScreenPosition([9, 0]) - expect(editor.getLastSelection().isReversed()).toBe false - - describe ".selectToBeginningOfNextParagraph()", -> - it "selects from the cursor to first line of the next paragraph", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - editor.selectToBeginningOfNextParagraph() - - selections = editor.getSelections() - expect(selections.length).toBe 1 - expect(selections[0].getScreenRange()).toEqual [[3, 0], [10, 0]] - - describe ".selectToBeginningOfPreviousParagraph()", -> - it "selects from the cursor to the first line of the previous paragraph", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - editor.selectToBeginningOfPreviousParagraph() - - selections = editor.getSelections() - expect(selections.length).toBe 1 - expect(selections[0].getScreenRange()).toEqual [[0, 0], [5, 6]] - - it "merges selections if they intersect, maintaining the directionality of the last selection", -> - editor.setCursorScreenPosition([4, 10]) - editor.selectToScreenPosition([5, 27]) - editor.addCursorAtScreenPosition([3, 10]) - editor.selectToScreenPosition([6, 27]) - - selections = editor.getSelections() - expect(selections.length).toBe 1 - [selection1] = selections - expect(selection1.getScreenRange()).toEqual [[3, 10], [6, 27]] - expect(selection1.isReversed()).toBeFalsy() - - editor.addCursorAtScreenPosition([7, 4]) - editor.selectToScreenPosition([4, 11]) - - selections = editor.getSelections() - expect(selections.length).toBe 1 - [selection1] = selections - expect(selection1.getScreenRange()).toEqual [[3, 10], [7, 4]] - expect(selection1.isReversed()).toBeTruthy() - - describe ".selectToTop()", -> - it "selects text from cursor position to the top of the buffer", -> - editor.setCursorScreenPosition [11, 2] - editor.addCursorAtScreenPosition [10, 0] - editor.selectToTop() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - expect(editor.getLastSelection().getBufferRange()).toEqual [[0, 0], [11, 2]] - expect(editor.getLastSelection().isReversed()).toBeTruthy() - - describe ".selectToBottom()", -> - it "selects text from cursor position to the bottom of the buffer", -> - editor.setCursorScreenPosition [10, 0] - editor.addCursorAtScreenPosition [9, 3] - editor.selectToBottom() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [12, 2] - expect(editor.getLastSelection().getBufferRange()).toEqual [[9, 3], [12, 2]] - expect(editor.getLastSelection().isReversed()).toBeFalsy() - - describe ".selectAll()", -> - it "selects the entire buffer", -> - editor.selectAll() - expect(editor.getLastSelection().getBufferRange()).toEqual buffer.getRange() - - describe ".selectToBeginningOfLine()", -> - it "selects text from cursor position to beginning of line", -> - editor.setCursorScreenPosition [12, 2] - editor.addCursorAtScreenPosition [11, 3] - - editor.selectToBeginningOfLine() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [12, 0] - expect(cursor2.getBufferPosition()).toEqual [11, 0] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[12, 0], [12, 2]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[11, 0], [11, 3]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".selectToEndOfLine()", -> - it "selects text from cursor position to end of line", -> - editor.setCursorScreenPosition [12, 0] - editor.addCursorAtScreenPosition [11, 3] - - editor.selectToEndOfLine() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [12, 2] - expect(cursor2.getBufferPosition()).toEqual [11, 44] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[12, 0], [12, 2]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[11, 3], [11, 44]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectLinesContainingCursors()", -> - it "selects to the entire line (including newlines) at given row", -> - editor.setCursorScreenPosition([1, 2]) - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[1, 0], [2, 0]] - expect(editor.getSelectedText()).toBe " var sort = function(items) {\n" - - editor.setCursorScreenPosition([12, 2]) - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[12, 0], [12, 2]] - - editor.setCursorBufferPosition([0, 2]) - editor.selectLinesContainingCursors() - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [2, 0]] - - describe "when the selection spans multiple row", -> - it "selects from the beginning of the first line to the last line", -> - selection = editor.getLastSelection() - selection.setBufferRange [[1, 10], [3, 20]] - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[1, 0], [4, 0]] - - describe ".selectToBeginningOfWord()", -> - it "selects text from cursor position to beginning of word", -> - editor.setCursorScreenPosition [0, 13] - editor.addCursorAtScreenPosition [3, 49] - - editor.selectToBeginningOfWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [3, 47] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 13]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[3, 47], [3, 49]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".selectToEndOfWord()", -> - it "selects text from cursor position to end of word", -> - editor.setCursorScreenPosition [0, 4] - editor.addCursorAtScreenPosition [3, 48] - - editor.selectToEndOfWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 50] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 13]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 50]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectToBeginningOfNextWord()", -> - it "selects text from cursor position to beginning of next word", -> - editor.setCursorScreenPosition [0, 4] - editor.addCursorAtScreenPosition [3, 48] - - editor.selectToBeginningOfNextWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [3, 51] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 14]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 51]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectToPreviousWordBoundary()", -> - it "select to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 0] - editor.addCursorAtBufferPosition [3, 4] - editor.addCursorAtBufferPosition [3, 14] - - editor.selectToPreviousWordBoundary() - - expect(editor.getSelections().length).toBe 4 - [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 4]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[2, 0], [1, 30]] - expect(selection2.isReversed()).toBeTruthy() - expect(selection3.getBufferRange()).toEqual [[3, 4], [3, 0]] - expect(selection3.isReversed()).toBeTruthy() - expect(selection4.getBufferRange()).toEqual [[3, 14], [3, 13]] - expect(selection4.isReversed()).toBeTruthy() - - describe ".selectToNextWordBoundary()", -> - it "select to the next word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 40] - editor.addCursorAtBufferPosition [4, 0] - editor.addCursorAtBufferPosition [3, 30] - - editor.selectToNextWordBoundary() - - expect(editor.getSelections().length).toBe 4 - [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 13]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[2, 40], [3, 0]] - expect(selection2.isReversed()).toBeFalsy() - expect(selection3.getBufferRange()).toEqual [[4, 0], [4, 4]] - expect(selection3.isReversed()).toBeFalsy() - expect(selection4.getBufferRange()).toEqual [[3, 30], [3, 31]] - expect(selection4.isReversed()).toBeFalsy() - - describe ".selectToPreviousSubwordBoundary", -> - it "selects subwords", -> - editor.setText("") - editor.insertText("_word\n") - editor.insertText(" getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 5]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 5]) - editor.addCursorAtBufferPosition([3, 3]) - [selection1, selection2, selection3, selection4] = editor.getSelections() - - editor.selectToPreviousSubwordBoundary() - expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 5]]) - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual([[1, 4], [1, 7]]) - expect(selection2.isReversed()).toBeTruthy() - expect(selection3.getBufferRange()).toEqual([[2, 3], [2, 5]]) - expect(selection3.isReversed()).toBeTruthy() - expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) - expect(selection4.isReversed()).toBeTruthy() - - describe ".selectToNextSubwordBoundary", -> - it "selects subwords", -> - editor.setText("") - editor.insertText("word_\n") - editor.insertText("getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 1]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 2]) - editor.addCursorAtBufferPosition([3, 1]) - [selection1, selection2, selection3, selection4] = editor.getSelections() - - editor.selectToNextSubwordBoundary() - expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 4]]) - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual([[1, 7], [1, 11]]) - expect(selection2.isReversed()).toBeFalsy() - expect(selection3.getBufferRange()).toEqual([[2, 2], [2, 5]]) - expect(selection3.isReversed()).toBeFalsy() - expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) - expect(selection4.isReversed()).toBeFalsy() - - describe ".deleteToBeginningOfSubword", -> - it "deletes subwords", -> - editor.setText("") - editor.insertText("_word\n") - editor.insertText(" getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 5]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 5]) - editor.addCursorAtBufferPosition([3, 3]) - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('_') - expect(buffer.lineForRow(1)).toBe(' getviousWord') - expect(buffer.lineForRow(2)).toBe('e, ') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 1]) - expect(cursor2.getBufferPosition()).toEqual([1, 4]) - expect(cursor3.getBufferPosition()).toEqual([2, 3]) - expect(cursor4.getBufferPosition()).toEqual([3, 1]) - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe(' viousWord') - expect(buffer.lineForRow(2)).toBe('e ') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 1]) - expect(cursor3.getBufferPosition()).toEqual([2, 1]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe('viousWord') - expect(buffer.lineForRow(2)).toBe(' ') - expect(buffer.lineForRow(3)).toBe('') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 0]) - expect(cursor4.getBufferPosition()).toEqual([2, 1]) - - describe ".deleteToEndOfSubword", -> - it "deletes subwords", -> - editor.setText("") - editor.insertText("word_\n") - editor.insertText("getPreviousWord \n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 0]) - editor.addCursorAtBufferPosition([1, 0]) - editor.addCursorAtBufferPosition([2, 2]) - editor.addCursorAtBufferPosition([3, 0]) - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.deleteToEndOfSubword() - expect(buffer.lineForRow(0)).toBe('_') - expect(buffer.lineForRow(1)).toBe('PreviousWord ') - expect(buffer.lineForRow(2)).toBe('e, ') - expect(buffer.lineForRow(3)).toBe('88 ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 2]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - editor.deleteToEndOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe('Word ') - expect(buffer.lineForRow(2)).toBe('e,') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 2]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - describe ".selectWordsContainingCursors()", -> - describe "when the cursor is inside a word", -> - it "selects the entire word", -> - editor.setCursorScreenPosition([0, 8]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'quicksort' - - describe "when the cursor is between two words", -> - it "selects the word the cursor is on", -> - editor.setCursorScreenPosition([0, 4]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.setCursorScreenPosition([0, 3]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'var' - - describe "when the cursor is inside a region of whitespace", -> - it "selects the whitespace region", -> - editor.setCursorScreenPosition([5, 2]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[5, 0], [5, 6]] - - editor.setCursorScreenPosition([5, 0]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[5, 0], [5, 6]] - - describe "when the cursor is at the end of the text", -> - it "select the previous word", -> - editor.buffer.append 'word' - editor.moveToBottom() - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[12, 2], [12, 6]] - - it "selects words based on the non-word characters configured at the cursor's current scope", -> - editor.setText("one-one; 'two-two'; three-three") - - editor.setCursorBufferPosition([0, 1]) - editor.addCursorAtBufferPosition([0, 12]) - - scopeDescriptors = editor.getCursors().map (c) -> c.getScopeDescriptor() - expect(scopeDescriptors[0].getScopesArray()).toEqual(['source.js']) - expect(scopeDescriptors[1].getScopesArray()).toEqual(['source.js', 'string.quoted.single.js']) - - editor.setScopedSettingsDelegate({ - getNonWordCharacters: (scopes) -> - result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' - if (scopes.some (scope) -> scope.startsWith('string')) - result - else - result + '-' - }) - - editor.selectWordsContainingCursors() - - expect(editor.getSelections()[0].getText()).toBe('one') - expect(editor.getSelections()[1].getText()).toBe('two-two') - - describe ".selectToFirstCharacterOfLine()", -> - it "moves to the first character of the current line or the beginning of the line if it's already on the first character", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - - editor.selectToFirstCharacterOfLine() - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 2] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 0], [0, 5]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1, 2], [1, 7]] - expect(selection2.isReversed()).toBeTruthy() - - editor.selectToFirstCharacterOfLine() - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 0], [0, 5]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1, 0], [1, 7]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".setSelectedBufferRanges(ranges)", -> - it "clears existing selections and creates selections for each of the given ranges", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [3, 3]], [[4, 4], [5, 5]]] - - editor.setSelectedBufferRanges([[[5, 5], [6, 6]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[5, 5], [6, 6]]] - - it "merges intersecting selections", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [5, 5]]] - - it "does not merge non-empty adjacent selections", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [3, 3]], [[3, 3], [5, 5]]] - - it "recycles existing selection instances", -> - selection = editor.getLastSelection() - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) - - [selection1, selection2] = editor.getSelections() - expect(selection1).toBe selection - expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] - - describe "when the 'preserveFolds' option is false (the default)", -> - it "removes folds that contain the selections", -> - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - editor.foldBufferRowRange(1, 4) - editor.foldBufferRowRange(2, 3) - editor.foldBufferRowRange(6, 8) - editor.foldBufferRowRange(10, 11) - - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 6], [7, 7]]]) - expect(editor.isFoldedAtScreenRow(1)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(6)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() - - describe "when the 'preserveFolds' option is true", -> - it "does not remove folds that contain the selections", -> - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - editor.foldBufferRowRange(1, 4) - editor.foldBufferRowRange(6, 8) - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 0], [6, 1]]], preserveFolds: true) - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - - describe ".setSelectedScreenRanges(ranges)", -> - beforeEach -> - editor.foldBufferRow(4) - - it "clears existing selections and creates selections for each of the given ranges", -> - editor.setSelectedScreenRanges([[[3, 4], [3, 7]], [[5, 4], [5, 7]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[3, 4], [3, 7]], [[8, 4], [8, 7]]] - - editor.setSelectedScreenRanges([[[6, 2], [6, 4]]]) - expect(editor.getSelectedScreenRanges()).toEqual [[[6, 2], [6, 4]]] - - it "merges intersecting selections and unfolds the fold which contain them", -> - editor.foldBufferRow(0) - - # Use buffer ranges because only the first line is on screen - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [5, 5]]] - - it "recycles existing selection instances", -> - selection = editor.getLastSelection() - editor.setSelectedScreenRanges([[[2, 2], [3, 4]], [[4, 4], [5, 5]]]) - - [selection1, selection2] = editor.getSelections() - expect(selection1).toBe selection - expect(selection1.getScreenRange()).toEqual [[2, 2], [3, 4]] - - describe ".selectMarker(marker)", -> - describe "if the marker is valid", -> - it "selects the marker's range and returns the selected range", -> - marker = editor.markBufferRange([[0, 1], [3, 3]]) - expect(editor.selectMarker(marker)).toEqual [[0, 1], [3, 3]] - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 3]] - - describe "if the marker is invalid", -> - it "does not change the selection and returns a falsy value", -> - marker = editor.markBufferRange([[0, 1], [3, 3]]) - marker.destroy() - expect(editor.selectMarker(marker)).toBeFalsy() - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 0]] - - describe ".addSelectionForBufferRange(bufferRange)", -> - it "adds a selection for the specified buffer range", -> - editor.addSelectionForBufferRange([[3, 4], [5, 6]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 0]], [[3, 4], [5, 6]]] - - describe ".addSelectionBelow()", -> - describe "when the selection is non-empty", -> - it "selects the same region of the line below current selections if possible", -> - editor.setSelectedBufferRange([[3, 16], [3, 21]]) - editor.addSelectionForBufferRange([[3, 25], [3, 34]]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 16], [3, 21]] - [[3, 25], [3, 34]] - [[4, 16], [4, 21]] - [[4, 25], [4, 29]] - ] - - it "skips lines that are too short to create a non-empty selection", -> - editor.setSelectedBufferRange([[3, 31], [3, 38]]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 31], [3, 38]] - [[6, 31], [6, 38]] - ] - - it "honors the original selection's range (goal range) when adding across shorter lines", -> - editor.setSelectedBufferRange([[3, 22], [3, 38]]) - editor.addSelectionBelow() - editor.addSelectionBelow() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 38]] - [[4, 22], [4, 29]] - [[5, 22], [5, 30]] - [[6, 22], [6, 38]] - ] - - it "clears selection goal ranges when the selection changes", -> - editor.setSelectedBufferRange([[3, 22], [3, 38]]) - editor.addSelectionBelow() - editor.selectLeft() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 37]] - [[4, 22], [4, 29]] - [[5, 22], [5, 28]] - ] - - # goal range from previous add selection is honored next time - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 37]] - [[4, 22], [4, 29]] - [[5, 22], [5, 30]] # select to end of line 5 because line 4's goal range was reset by line 3 previously - [[6, 22], [6, 28]] - ] - - it "can add selections to soft-wrapped line segments", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(40) - editor.setDefaultCharWidth(1) - - editor.setSelectedScreenRange([[3, 10], [3, 15]]) - editor.addSelectionBelow() - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 10], [3, 15]] - [[4, 10], [4, 15]] - ] - - it "takes atomic tokens into account", -> - waitsForPromise -> - atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', autoIndent: false).then (o) -> editor = o - - runs -> - editor.setSelectedBufferRange([[2, 1], [2, 3]]) - editor.addSelectionBelow() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 1], [2, 3]] - [[3, 1], [3, 2]] - ] - - describe "when the selection is empty", -> - describe "when lines are soft-wrapped", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - it "skips soft-wrap indentation tokens", -> - editor.setCursorScreenPosition([3, 0]) - editor.addSelectionBelow() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 0], [3, 0]] - [[4, 4], [4, 4]] - ] - - it "does not skip them if they're shorter than the current column", -> - editor.setCursorScreenPosition([3, 37]) - editor.addSelectionBelow() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 37], [3, 37]] - [[4, 26], [4, 26]] - ] - - it "does not skip lines that are shorter than the current column", -> - editor.setCursorBufferPosition([3, 36]) - editor.addSelectionBelow() - editor.addSelectionBelow() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 36], [3, 36]] - [[4, 29], [4, 29]] - [[5, 30], [5, 30]] - [[6, 36], [6, 36]] - ] - - it "skips empty lines when the column is non-zero", -> - editor.setCursorBufferPosition([9, 4]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[9, 4], [9, 4]] - [[11, 4], [11, 4]] - ] - - it "does not skip empty lines when the column is zero", -> - editor.setCursorBufferPosition([9, 0]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[9, 0], [9, 0]] - [[10, 0], [10, 0]] - ] - - describe ".addSelectionAbove()", -> - describe "when the selection is non-empty", -> - it "selects the same region of the line above current selections if possible", -> - editor.setSelectedBufferRange([[3, 16], [3, 21]]) - editor.addSelectionForBufferRange([[3, 37], [3, 44]]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 16], [3, 21]] - [[3, 37], [3, 44]] - [[2, 16], [2, 21]] - [[2, 37], [2, 40]] - ] - - it "skips lines that are too short to create a non-empty selection", -> - editor.setSelectedBufferRange([[6, 31], [6, 38]]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 31], [6, 38]] - [[3, 31], [3, 38]] - ] - - it "honors the original selection's range (goal range) when adding across shorter lines", -> - editor.setSelectedBufferRange([[6, 22], [6, 38]]) - editor.addSelectionAbove() - editor.addSelectionAbove() - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 22], [6, 38]] - [[5, 22], [5, 30]] - [[4, 22], [4, 29]] - [[3, 22], [3, 38]] - ] - - it "can add selections to soft-wrapped line segments", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - editor.setSelectedScreenRange([[4, 10], [4, 15]]) - editor.addSelectionAbove() - expect(editor.getSelectedScreenRanges()).toEqual [ - [[4, 10], [4, 15]] - [[3, 10], [3, 15]] - ] - - it "takes atomic tokens into account", -> - waitsForPromise -> - atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', autoIndent: false).then (o) -> editor = o - - runs -> - editor.setSelectedBufferRange([[3, 1], [3, 2]]) - editor.addSelectionAbove() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 1], [3, 2]] - [[2, 1], [2, 3]] - ] - - describe "when the selection is empty", -> - describe "when lines are soft-wrapped", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - it "skips soft-wrap indentation tokens", -> - editor.setCursorScreenPosition([5, 0]) - editor.addSelectionAbove() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[5, 0], [5, 0]] - [[4, 4], [4, 4]] - ] - - it "does not skip them if they're shorter than the current column", -> - editor.setCursorScreenPosition([5, 29]) - editor.addSelectionAbove() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[5, 29], [5, 29]] - [[4, 26], [4, 26]] - ] - - it "does not skip lines that are shorter than the current column", -> - editor.setCursorBufferPosition([6, 36]) - editor.addSelectionAbove() - editor.addSelectionAbove() - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 36], [6, 36]] - [[5, 30], [5, 30]] - [[4, 29], [4, 29]] - [[3, 36], [3, 36]] - ] - - it "skips empty lines when the column is non-zero", -> - editor.setCursorBufferPosition([11, 4]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[11, 4], [11, 4]] - [[9, 4], [9, 4]] - ] - - it "does not skip empty lines when the column is zero", -> - editor.setCursorBufferPosition([10, 0]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[10, 0], [10, 0]] - [[9, 0], [9, 0]] - ] - - describe ".splitSelectionsIntoLines()", -> - it "splits all multi-line selections into one selection per line", -> - editor.setSelectedBufferRange([[0, 3], [2, 4]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 29]] - [[1, 0], [1, 30]] - [[2, 0], [2, 4]] - ] - - editor.setSelectedBufferRange([[0, 3], [1, 10]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 29]] - [[1, 0], [1, 10]] - ] - - editor.setSelectedBufferRange([[0, 0], [0, 3]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]]] - - describe "::consolidateSelections()", -> - makeMultipleSelections = -> - selection.setBufferRange [[3, 16], [3, 21]] - selection2 = editor.addSelectionForBufferRange([[3, 25], [3, 34]]) - selection3 = editor.addSelectionForBufferRange([[8, 4], [8, 10]]) - selection4 = editor.addSelectionForBufferRange([[1, 6], [1, 10]]) - expect(editor.getSelections()).toEqual [selection, selection2, selection3, selection4] - [selection, selection2, selection3, selection4] - - it "destroys all selections but the oldest selection and autoscrolls to it, returning true if any selections were destroyed", -> - [selection1] = makeMultipleSelections() - - autoscrollEvents = [] - editor.onDidRequestAutoscroll (event) -> autoscrollEvents.push(event) - - expect(editor.consolidateSelections()).toBeTruthy() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.isEmpty()).toBeFalsy() - expect(editor.consolidateSelections()).toBeFalsy() - expect(editor.getSelections()).toEqual [selection1] - - expect(autoscrollEvents).toEqual([ - {screenRange: selection1.getScreenRange(), options: {center: true, reversed: false}} - ]) - - describe "when the cursor is moved while there is a selection", -> - makeSelection = -> selection.setBufferRange [[1, 2], [1, 5]] - - it "clears the selection", -> - makeSelection() - editor.moveDown() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveUp() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveLeft() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveRight() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.setCursorScreenPosition([3, 3]) - expect(selection.isEmpty()).toBeTruthy() - - it "does not share selections between different edit sessions for the same buffer", -> - editor2 = null - waitsForPromise -> - atom.workspace.getActivePane().splitRight() - atom.workspace.open(editor.getPath()).then (o) -> editor2 = o - - runs -> - expect(editor2.getText()).toBe(editor.getText()) - editor.setSelectedBufferRanges([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) - editor2.setSelectedBufferRanges([[[8, 7], [6, 5]], [[4, 3], [2, 1]]]) - expect(editor2.getSelectedBufferRanges()).not.toEqual editor.getSelectedBufferRanges() - - describe "buffer manipulation", -> - describe ".moveLineUp", -> - it "moves the line under the cursor up", -> - editor.setCursorBufferPosition([1, 0]) - editor.moveLineUp() - expect(editor.getTextInBufferRange([[0, 0], [0, 30]])).toBe " var sort = function(items) {" - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 0 - - it "updates the line's indentation when the the autoIndent setting is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([1, 0]) - editor.moveLineUp() - expect(editor.indentationForBufferRow(0)).toBe 0 - expect(editor.indentationForBufferRow(1)).toBe 0 - - describe "when there is a single selection", -> - describe "when the selection spans a single line", -> - describe "when there is no fold in the preceeding row", -> - it "moves the line to the preceding row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.setSelectedBufferRange([[3, 2], [3, 9]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [2, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is at the beginning of a fold", -> - it "moves the line to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [4, 9]], preserveFolds: true) - expect(editor.getSelectedBufferRange()).toEqual [[4, 2], [4, 9]] - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [3, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - - describe "when the preceding row consists of folded code", -> - it "moves the line above the folded row and perseveres the correct folds", -> - expect(editor.lineTextForBufferRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[8, 0], [8, 4]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 0], [4, 4]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the selection spans multiple lines", -> - it "moves the lines spanned by the selection to the preceding row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[3, 2], [4, 9]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " if (items.length <= 1) return items;" - - describe "when the selection's end intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[3, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " if (items.length <= 1) return items;" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - describe "when the selection's start intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [8, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [7, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(8)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - - describe "when the selection spans multiple lines, but ends at column 0", -> - it "does not move the last line of the selection", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[3, 2], [4, 0]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 0]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - describe "when the preceeding row is a folded row", -> - it "moves the lines spanned by the selection to the preceeding row, but preserves the folded code", -> - expect(editor.lineTextForBufferRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - editor.foldBufferRowRange(4, 7) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[8, 0], [9, 2]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 0], [5, 2]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " };" - expect(editor.lineTextForBufferRow(6)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(5)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - describe "when there are multiple selections", -> - describe "when all the selections span different lines", -> - describe "when there is no folds", -> - it "moves all lines that are spanned by a selection to the preceding row", -> - editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [0, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]] - expect(editor.lineTextForBufferRow(0)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(1)).toBe "var quicksort = function () {" - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " current = items.shift();" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - describe "when one selection intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRanges([ - [[2, 2], [2, 9]], - [[4, 2], [4, 9]] - ], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 2], [1, 9]], - [[3, 2], [3, 9]] - ]) - - expect(editor.lineTextForBufferRow(1)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - describe "when there is a fold", -> - it "moves all lines that spanned by a selection to preceding row, preserving all folds", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[8, 0], [8, 3]], [[11, 0], [11, 5]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[4, 0], [4, 3]], [[10, 0], [10, 5]]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(10)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe 'when there are many folds', -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample-with-many-folds.js', autoIndent: false).then (o) -> editor = o - - describe 'and many selections intersects folded rows', -> - it 'moves and preserves all the folds', -> - editor.foldBufferRowRange(2, 4) - editor.foldBufferRowRange(7, 9) - - editor.setSelectedBufferRanges([ - [[1, 0], [5, 4]], - [[7, 0], [7, 4]] - ], preserveFolds: true) - - editor.moveLineUp() - - expect(editor.lineTextForBufferRow(1)).toEqual "function f3() {" - expect(editor.lineTextForBufferRow(4)).toEqual "6;" - expect(editor.lineTextForBufferRow(5)).toEqual "1;" - expect(editor.lineTextForBufferRow(6)).toEqual "function f8() {" - expect(editor.lineTextForBufferRow(9)).toEqual "7;" - - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when some of the selections span the same lines", -> - it "moves lines that contain multiple selections correctly", -> - editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [2, 9]], [[2, 12], [2, 13]]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when one of the selections spans line 0", -> - it "doesn't move any lines, since line 0 can't move", -> - editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]] - expect(buffer.isModified()).toBe false - - describe "when one of the selections spans the last line, and it is empty", -> - it "doesn't move any lines, since the last line can't move", -> - buffer.append('\n') - editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]] - - describe ".moveLineDown", -> - it "moves the line under the cursor down", -> - editor.setCursorBufferPosition([0, 0]) - editor.moveLineDown() - expect(editor.getTextInBufferRange([[1, 0], [1, 31]])).toBe "var quicksort = function () {" - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 0 - - it "updates the line's indentation when the editor.autoIndent setting is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([0, 0]) - editor.moveLineDown() - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 2 - - describe "when there is a single selection", -> - describe "when the selection spans a single line", -> - describe "when there is no fold in the following row", -> - it "moves the line to the following row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.setSelectedBufferRange([[2, 2], [2, 9]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is at the beginning of a fold", -> - it "moves the line to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[5, 2], [5, 9]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the following row is a folded row", -> - it "moves the line below the folded row and preserves the fold", -> - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[3, 0], [3, 4]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[7, 0], [7, 4]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when the selection spans multiple lines", -> - it "moves the lines spanned by the selection to the following row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[2, 2], [3, 9]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [4, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when the selection spans multiple lines, but ends at column 0", -> - it "does not move the last line of the selection", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[2, 2], [3, 0]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [4, 0]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - describe "when the selection's end intersects a fold", -> - it "moves the lines to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[3, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 2], [5, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the selection's start intersects a fold", -> - it "moves the lines to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [8, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[5, 2], [9, 9]] - expect(editor.lineTextForBufferRow(4)).toBe " };" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(9)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - describe "when the following row is a folded row", -> - it "moves the lines spanned by the selection to the following row, but preserves the folded code", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.foldBufferRowRange(4, 7) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[2, 0], [3, 2]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[6, 0], [7, 2]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - - describe "when the last line of selection does not end with a valid line ending", -> - it "appends line ending to last line and moves the lines spanned by the selection to the preceeding row", -> - expect(editor.lineTextForBufferRow(9)).toBe " };" - expect(editor.lineTextForBufferRow(10)).toBe "" - expect(editor.lineTextForBufferRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.lineTextForBufferRow(12)).toBe "};" - - editor.setSelectedBufferRange([[10, 0], [12, 2]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[9, 0], [11, 2]] - expect(editor.lineTextForBufferRow(9)).toBe "" - expect(editor.lineTextForBufferRow(10)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.lineTextForBufferRow(11)).toBe "};" - expect(editor.lineTextForBufferRow(12)).toBe " };" - - describe "when there are multiple selections", -> - describe "when all the selections span different lines", -> - describe "when there is no folds", -> - it "moves all lines that are spanned by a selection to the following row", -> - editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[6, 2], [6, 9]], [[4, 2], [4, 9]], [[2, 2], [2, 9]]] - expect(editor.lineTextForBufferRow(1)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(6)).toBe " current = items.shift();" - - describe 'when there are many folds', -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample-with-many-folds.js', autoIndent: false).then (o) -> editor = o - - describe 'and many selections intersects folded rows', -> - it 'moves and preserves all the folds', -> - editor.foldBufferRowRange(2, 4) - editor.foldBufferRowRange(7, 9) - - editor.setSelectedBufferRanges([ - [[2, 0], [2, 4]], - [[6, 0], [10, 4]] - ], preserveFolds: true) - - editor.moveLineDown() - - expect(editor.lineTextForBufferRow(2)).toEqual "6;" - expect(editor.lineTextForBufferRow(3)).toEqual "function f3() {" - expect(editor.lineTextForBufferRow(6)).toEqual "12;" - expect(editor.lineTextForBufferRow(7)).toEqual "7;" - expect(editor.lineTextForBufferRow(8)).toEqual "function f8() {" - expect(editor.lineTextForBufferRow(11)).toEqual "11;" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(11)).toBeFalsy() - - describe "when there is a fold below one of the selected row", -> - it "moves all lines spanned by a selection to the following row, preserving the fold", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[1, 2], [1, 6]], [[3, 0], [3, 4]], [[8, 0], [8, 3]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[9, 0], [9, 3]], [[7, 0], [7, 4]], [[2, 2], [2, 6]]] - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(9)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - describe "when there is a fold below a group of multiple selections without any lines with no selection in-between", -> - it "moves all the lines below the fold, preserving the fold", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[2, 2], [2, 6]], [[3, 0], [3, 4]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[7, 0], [7, 4]], [[6, 2], [6, 6]]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when one selection intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRanges([ - [[2, 2], [2, 9]], - [[4, 2], [4, 9]] - ], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual([ - [[5, 2], [5, 9]] - [[3, 2], [3, 9]], - ]) - - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when some of the selections span the same lines", -> - it "moves lines that contain multiple selections correctly", -> - editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[4, 12], [4, 13]], [[4, 2], [4, 9]]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - - describe "when the selections are above a wrapped line", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(80) - editor.setText(""" - 1 - 2 - Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. - 3 - 4 - """) - - it 'moves the lines past the soft wrapped line', -> - editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 0]]]) - - editor.moveLineDown() - - expect(editor.lineTextForBufferRow(0)).not.toBe "2" - expect(editor.lineTextForBufferRow(1)).toBe "1" - expect(editor.lineTextForBufferRow(2)).toBe "2" - - describe "when the line is the last buffer row", -> - it "doesn't move it", -> - editor.setText("abc\ndef") - editor.setCursorBufferPosition([1, 0]) - editor.moveLineDown() - expect(editor.getText()).toBe("abc\ndef") - - describe ".insertText(text)", -> - describe "when there is a single selection", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 0], [1, 2]]) - - it "replaces the selection with the given text", -> - range = editor.insertText('xxx') - expect(range).toEqual [ [[1, 0], [1, 3]] ] - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - describe "when there are multiple empty selections", -> - describe "when the cursors are on the same line", -> - it "inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", -> - editor.setCursorScreenPosition([1, 2]) - editor.addCursorAtScreenPosition([1, 5]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe ' xxxvarxxx sort = function(items) {' - [cursor1, cursor2] = editor.getCursors() - - expect(cursor1.getBufferPosition()).toEqual [1, 5] - expect(cursor2.getBufferPosition()).toEqual [1, 11] - - describe "when the cursors are on different lines", -> - it "inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", -> - editor.setCursorScreenPosition([1, 2]) - editor.addCursorAtScreenPosition([2, 4]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe ' xxxvar sort = function(items) {' - expect(buffer.lineForRow(2)).toBe ' xxxif (items.length <= 1) return items;' - [cursor1, cursor2] = editor.getCursors() - - expect(cursor1.getBufferPosition()).toEqual [1, 5] - expect(cursor2.getBufferPosition()).toEqual [2, 7] - - describe "when there are multiple non-empty selections", -> - describe "when the selections are on the same line", -> - it "replaces each selection range with the inserted characters", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 22], [0, 24]]]) - editor.insertText("x") - - [cursor1, cursor2] = editor.getCursors() - [selection1, selection2] = editor.getSelections() - - expect(cursor1.getScreenPosition()).toEqual [0, 5] - expect(cursor2.getScreenPosition()).toEqual [0, 15] - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - expect(editor.lineTextForBufferRow(0)).toBe "var x = functix () {" - - describe "when the selections are on different lines", -> - it "replaces each selection with the given text, clears the selections, and places the cursor at the end of each selection's inserted text", -> - editor.setSelectedBufferRanges([[[1, 0], [1, 2]], [[2, 0], [2, 4]]]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - expect(buffer.lineForRow(2)).toBe 'xxxif (items.length <= 1) return items;' - [selection1, selection2] = editor.getSelections() - - expect(selection1.isEmpty()).toBeTruthy() - expect(selection1.cursor.getBufferPosition()).toEqual [1, 3] - expect(selection2.isEmpty()).toBeTruthy() - expect(selection2.cursor.getBufferPosition()).toEqual [2, 3] - - describe "when there is a selection that ends on a folded line", -> - it "destroys the selection", -> - editor.foldBufferRowRange(2, 4) - editor.setSelectedBufferRange([[1, 0], [2, 0]]) - editor.insertText('holy cow') - expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() - - describe "when there are ::onWillInsertText and ::onDidInsertText observers", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 0], [1, 2]]) - - it "notifies the observers when inserting text", -> - willInsertSpy = jasmine.createSpy().andCallFake -> - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) {' - - didInsertSpy = jasmine.createSpy().andCallFake -> - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - editor.onWillInsertText(willInsertSpy) - editor.onDidInsertText(didInsertSpy) - - expect(editor.insertText('xxx')).toBeTruthy() - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - expect(willInsertSpy).toHaveBeenCalled() - expect(didInsertSpy).toHaveBeenCalled() - - options = willInsertSpy.mostRecentCall.args[0] - expect(options.text).toBe 'xxx' - expect(options.cancel).toBeDefined() - - options = didInsertSpy.mostRecentCall.args[0] - expect(options.text).toBe 'xxx' - - it "cancels text insertion when an ::onWillInsertText observer calls cancel on an event", -> - willInsertSpy = jasmine.createSpy().andCallFake ({cancel}) -> - cancel() - - didInsertSpy = jasmine.createSpy() - - editor.onWillInsertText(willInsertSpy) - editor.onDidInsertText(didInsertSpy) - - expect(editor.insertText('xxx')).toBe false - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) {' - - expect(willInsertSpy).toHaveBeenCalled() - expect(didInsertSpy).not.toHaveBeenCalled() - - describe "when the undo option is set to 'skip'", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 2], [1, 2]]) - - it "does not undo the skipped operation", -> - range = editor.insertText('x') - range = editor.insertText('y', undo: 'skip') - editor.undo() - expect(buffer.lineForRow(1)).toBe ' yvar sort = function(items) {' - - describe ".insertNewline()", -> - describe "when there is a single cursor", -> - describe "when the cursor is at the beginning of a line", -> - it "inserts an empty line before it", -> - editor.setCursorScreenPosition(row: 1, column: 0) - - editor.insertNewline() - - expect(buffer.lineForRow(1)).toBe '' - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when the cursor is in the middle of a line", -> - it "splits the current line to form a new line", -> - editor.setCursorScreenPosition(row: 1, column: 6) - originalLine = buffer.lineForRow(1) - lineBelowOriginalLine = buffer.lineForRow(2) - - editor.insertNewline() - - expect(buffer.lineForRow(1)).toBe originalLine[0...6] - expect(buffer.lineForRow(2)).toBe originalLine[6..] - expect(buffer.lineForRow(3)).toBe lineBelowOriginalLine - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when the cursor is on the end of a line", -> - it "inserts an empty line after it", -> - editor.setCursorScreenPosition(row: 1, column: buffer.lineForRow(1).length) - - editor.insertNewline() - - expect(buffer.lineForRow(2)).toBe '' - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when there are multiple cursors", -> - describe "when the cursors are on the same line", -> - it "breaks the line at the cursor locations", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.insertNewline() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot" - expect(editor.lineTextForBufferRow(4)).toBe " = items.shift(), current" - expect(editor.lineTextForBufferRow(5)).toBe ", left = [], right = [];" - expect(editor.lineTextForBufferRow(6)).toBe " while(items.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [4, 0] - expect(cursor2.getBufferPosition()).toEqual [5, 0] - - describe "when the cursors are on different lines", -> - it "inserts newlines at each cursor location", -> - editor.setCursorScreenPosition([3, 0]) - editor.addCursorAtScreenPosition([6, 0]) - - editor.insertText("\n") - expect(editor.lineTextForBufferRow(3)).toBe "" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(6)).toBe " current = items.shift();" - expect(editor.lineTextForBufferRow(7)).toBe "" - expect(editor.lineTextForBufferRow(8)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(9)).toBe " }" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [4, 0] - expect(cursor2.getBufferPosition()).toEqual [8, 0] - - describe ".insertNewlineBelow()", -> - describe "when the operation is undone", -> - it "places the cursor back at the previous location", -> - editor.setCursorBufferPosition([0, 2]) - editor.insertNewlineBelow() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.undo() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - - it "inserts a newline below the cursor's current line, autoindents it, and moves the cursor to the end of the line", -> - editor.update({autoIndent: true}) - editor.insertNewlineBelow() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " " - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - - describe ".insertNewlineAbove()", -> - describe "when the cursor is on first line", -> - it "inserts a newline on the first line and moves the cursor to the first line", -> - editor.setCursorBufferPosition([0]) - editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - expect(editor.lineTextForBufferRow(0)).toBe '' - expect(editor.lineTextForBufferRow(1)).toBe 'var quicksort = function () {' - expect(editor.buffer.getLineCount()).toBe 14 - - describe "when the cursor is not on the first line", -> - it "inserts a newline above the current line and moves the cursor to the inserted line", -> - editor.setCursorBufferPosition([3, 4]) - editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [3, 0] - expect(editor.lineTextForBufferRow(3)).toBe '' - expect(editor.lineTextForBufferRow(4)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(editor.buffer.getLineCount()).toBe 14 - - editor.undo() - expect(editor.getCursorBufferPosition()).toEqual [3, 4] - - it "indents the new line to the correct level when editor.autoIndent is true", -> - editor.update({autoIndent: true}) - - editor.setText(' var test') - editor.setCursorBufferPosition([0, 2]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - expect(editor.lineTextForBufferRow(0)).toBe ' ' - expect(editor.lineTextForBufferRow(1)).toBe ' var test' - - editor.setText('\n var test') - editor.setCursorBufferPosition([1, 2]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - expect(editor.lineTextForBufferRow(0)).toBe '' - expect(editor.lineTextForBufferRow(1)).toBe ' ' - expect(editor.lineTextForBufferRow(2)).toBe ' var test' - - editor.setText('function() {\n}') - editor.setCursorBufferPosition([1, 1]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - expect(editor.lineTextForBufferRow(0)).toBe 'function() {' - expect(editor.lineTextForBufferRow(1)).toBe ' ' - expect(editor.lineTextForBufferRow(2)).toBe '}' - - describe ".insertNewLine()", -> - describe "when a new line is appended before a closing tag (e.g. by pressing enter before a selection)", -> - it "moves the line down and keeps the indentation level the same when editor.autoIndent is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([9, 2]) - editor.insertNewline() - expect(editor.lineTextForBufferRow(10)).toBe ' };' - - describe "when a newline is appended with a trailing closing tag behind the cursor (e.g. by pressing enter in the middel of a line)", -> - it "indents the new line to the correct level when editor.autoIndent is true and using a curly-bracket language", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.js")) - editor.setText('var test = function () {\n return true;};') - editor.setCursorBufferPosition([1, 14]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - - it "indents the new line to the current level when editor.autoIndent is true and no increaseIndentPattern is specified", -> - runs -> - editor.setGrammar(atom.grammars.selectGrammar("file")) - editor.update({autoIndent: true}) - editor.setText(' if true') - editor.setCursorBufferPosition([0, 8]) - editor.insertNewline() - expect(editor.getGrammar()).toBe atom.grammars.nullGrammar - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 1 - - it "indents the new line to the correct level when editor.autoIndent is true and using an off-side rule language", -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.coffee")) - editor.setText('if true\n return trueelse\n return false') - editor.setCursorBufferPosition([1, 13]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - expect(editor.indentationForBufferRow(3)).toBe 1 - - describe "when a newline is appended on a line that matches the decreaseNextIndentPattern", -> - it "indents the new line to the correct level when editor.autoIndent is true", -> - waitsForPromise -> - atom.packages.activatePackage('language-go') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.go")) - editor.setText('fmt.Printf("some%s",\n "thing")') - editor.setCursorBufferPosition([1, 10]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - - describe ".backspace()", -> - describe "when there is a single cursor", -> - changeScreenRangeHandler = null - - beforeEach -> - selection = editor.getLastSelection() - changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') - selection.onDidChangeRange changeScreenRangeHandler - - describe "when the cursor is on the middle of the line", -> - it "removes the character before the cursor", -> - editor.setCursorScreenPosition(row: 1, column: 7) - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - - editor.backspace() - - line = buffer.lineForRow(1) - expect(line).toBe " var ort = function(items) {" - expect(editor.getCursorScreenPosition()).toEqual {row: 1, column: 6} - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the cursor is at the beginning of a line", -> - it "joins it with the line above", -> - originalLine0 = buffer.lineForRow(0) - expect(originalLine0).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - - editor.setCursorScreenPosition(row: 1, column: 0) - editor.backspace() - - line0 = buffer.lineForRow(0) - line1 = buffer.lineForRow(1) - expect(line0).toBe "var quicksort = function () { var sort = function(items) {" - expect(line1).toBe " if (items.length <= 1) return items;" - expect(editor.getCursorScreenPosition()).toEqual [0, originalLine0.length] - - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the cursor is at the first column of the first line", -> - it "does nothing, but doesn't raise an error", -> - editor.setCursorScreenPosition(row: 0, column: 0) - editor.backspace() - - describe "when the cursor is after a fold", -> - it "deletes the folded range", -> - editor.foldBufferRange([[4, 7], [5, 8]]) - editor.setCursorBufferPosition([5, 8]) - editor.backspace() - - expect(buffer.lineForRow(4)).toBe " whirrent = items.shift();" - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - - describe "when the cursor is in the middle of a line below a fold", -> - it "backspaces as normal", -> - editor.setCursorScreenPosition([4, 0]) - editor.foldCurrentRow() - editor.setCursorScreenPosition([5, 5]) - editor.backspace() - - expect(buffer.lineForRow(7)).toBe " }" - expect(buffer.lineForRow(8)).toBe " eturn sort(left).concat(pivot).concat(sort(right));" - - describe "when the cursor is on a folded screen line", -> - it "deletes the contents of the fold before the cursor", -> - editor.setCursorBufferPosition([3, 0]) - editor.foldCurrentRow() - editor.backspace() - - expect(buffer.lineForRow(1)).toBe " var sort = function(items) var pivot = items.shift(), current, left = [], right = [];" - expect(editor.getCursorScreenPosition()).toEqual [1, 29] - - describe "when there are multiple cursors", -> - describe "when cursors are on the same line", -> - it "removes the characters preceding each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.backspace() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivo = items.shift(), curren, left = [], right = [];" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 12] - expect(cursor2.getBufferPosition()).toEqual [3, 36] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when cursors are on different lines", -> - describe "when the cursors are in the middle of their lines", -> - it "removes the characters preceding each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([4, 10]) - - editor.backspace() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivo = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " whileitems.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 12] - expect(cursor2.getBufferPosition()).toEqual [4, 9] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when the cursors are on the first column of their lines", -> - it "removes the newlines preceding each cursor", -> - editor.setCursorScreenPosition([3, 0]) - editor.addCursorAtScreenPosition([6, 0]) - - editor.backspace() - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items; var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " current = items.shift(); current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(5)).toBe " }" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [2, 40] - expect(cursor2.getBufferPosition()).toEqual [4, 30] - - describe "when there is a single selection", -> - it "deletes the selection, but not the character before it", -> - editor.setSelectedBufferRange([[0, 5], [0, 9]]) - editor.backspace() - expect(editor.buffer.lineForRow(0)).toBe 'var qsort = function () {' - - describe "when the selection ends on a folded line", -> - it "preserves the fold", -> - editor.setSelectedBufferRange([[3, 0], [4, 0]]) - editor.foldBufferRow(4) - editor.backspace() - - expect(buffer.lineForRow(3)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtScreenRow(3)).toBe(true) - - describe "when there are multiple selections", -> - it "removes all selected text", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - editor.backspace() - expect(editor.lineTextForBufferRow(0)).toBe 'var = () {' - - describe ".deleteToPreviousWordBoundary()", -> - describe "when no text is selected", -> - it "deletes to the previous word boundary", -> - editor.setCursorBufferPosition([0, 16]) - editor.addCursorAtBufferPosition([1, 21]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =function () {' - expect(buffer.lineForRow(1)).toBe ' var sort = (items) {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 13] - - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort function () {' - expect(buffer.lineForRow(1)).toBe ' var sort =(items) {' - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [1, 12] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".deleteToNextWordBoundary()", -> - describe "when no text is selected", -> - it "deletes to the next word boundary", -> - editor.setCursorBufferPosition([0, 15]) - editor.addCursorAtBufferPosition([1, 24]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =function () {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort = () {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =() {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it{' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".deleteToBeginningOfWord()", -> - describe "when no text is selected", -> - it "deletes all text between the cursor and the beginning of the word", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([3, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(ems) {' - expect(buffer.lineForRow(3)).toBe ' ar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 22] - expect(cursor2.getBufferPosition()).toEqual [3, 4] - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = functionems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return itemsar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 21] - expect(cursor2.getBufferPosition()).toEqual [2, 39] - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = ems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return ar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 13] - expect(cursor2.getBufferPosition()).toEqual [2, 34] - - editor.setText(' var sort') - editor.setCursorBufferPosition([0, 2]) - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(0)).toBe 'var sort' - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - - describe '.deleteToEndOfLine()', -> - describe 'when no text is selected', -> - it 'deletes all text between the cursor and the end of the line', -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it' - expect(buffer.lineForRow(2)).toBe ' i' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - describe 'when at the end of the line', -> - it 'deletes the next newline', -> - editor.setCursorBufferPosition([1, 30]) - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe 'when text is selected', -> - it 'deletes only the text in the selection', -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - - describe ".deleteToBeginningOfLine()", -> - describe "when no text is selected", -> - it "deletes all text between the cursor and the beginning of the line", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe 'ems) {' - expect(buffer.lineForRow(2)).toBe 'f (items.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 0] - expect(cursor2.getBufferPosition()).toEqual [2, 0] - - describe "when at the beginning of the line", -> - it "deletes the newline", -> - editor.setCursorBufferPosition([2]) - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe "when text is selected", -> - it "still deletes all text to beginning of the line", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe 'ems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' - - describe ".delete()", -> - describe "when there is a single cursor", -> - describe "when the cursor is on the middle of a line", -> - it "deletes the character following the cursor", -> - editor.setCursorScreenPosition([1, 6]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var ort = function(items) {' - - describe "when the cursor is on the end of a line", -> - it "joins the line with the following line", -> - editor.setCursorScreenPosition([1, buffer.lineForRow(1).length]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe "when the cursor is on the last column of the last line", -> - it "does nothing, but doesn't raise an error", -> - editor.setCursorScreenPosition([12, buffer.lineForRow(12).length]) - editor.delete() - expect(buffer.lineForRow(12)).toBe '};' - - describe "when the cursor is before a fold", -> - it "only deletes the lines inside the fold", -> - editor.foldBufferRange([[3, 6], [4, 8]]) - editor.setCursorScreenPosition([3, 6]) - cursorPositionBefore = editor.getCursorScreenPosition() - - editor.delete() - - expect(buffer.lineForRow(3)).toBe " vae(items.length > 0) {" - expect(buffer.lineForRow(4)).toBe " current = items.shift();" - expect(editor.getCursorScreenPosition()).toEqual cursorPositionBefore - - describe "when the cursor is in the middle a line above a fold", -> - it "deletes as normal", -> - editor.foldBufferRow(4) - editor.setCursorScreenPosition([3, 4]) - cursorPositionBefore = editor.getCursorScreenPosition() - - editor.delete() - - expect(buffer.lineForRow(3)).toBe " ar pivot = items.shift(), current, left = [], right = [];" - expect(editor.isFoldedAtScreenRow(4)).toBe(true) - expect(editor.getCursorScreenPosition()).toEqual [3, 4] - - describe "when the cursor is inside a fold", -> - it "removes the folded content after the cursor", -> - editor.foldBufferRange([[2, 6], [6, 21]]) - editor.setCursorBufferPosition([4, 9]) - - editor.delete() - - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(buffer.lineForRow(4)).toBe ' while ? left.push(current) : right.push(current);' - expect(buffer.lineForRow(5)).toBe ' }' - expect(editor.getCursorBufferPosition()).toEqual [4, 9] - - describe "when there are multiple cursors", -> - describe "when cursors are on the same line", -> - it "removes the characters following each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.delete() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot= items.shift(), current left = [], right = [];" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 37] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when cursors are on different lines", -> - describe "when the cursors are in the middle of the lines", -> - it "removes the characters following each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([4, 10]) - - editor.delete() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot= items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(tems.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 13] - expect(cursor2.getBufferPosition()).toEqual [4, 10] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when the cursors are at the end of their lines", -> - it "removes the newlines following each cursor", -> - editor.setCursorScreenPosition([0, 29]) - editor.addCursorAtScreenPosition([1, 30]) - - editor.delete() - - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort = function () { var sort = function(items) { if (items.length <= 1) return items;" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 29] - expect(cursor2.getBufferPosition()).toEqual [0, 59] - - describe "when there is a single selection", -> - it "deletes the selection, but not the character following it", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - expect(editor.getLastSelection().isEmpty()).toBeTruthy() - - describe "when there are multiple selections", -> - describe "when selections are on the same line", -> - it "removes all selected text", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - editor.delete() - expect(editor.lineTextForBufferRow(0)).toBe 'var = () {' - - describe ".deleteToEndOfWord()", -> - describe "when no text is selected", -> - it "deletes to the end of the word", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe ' i (items.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it {' - expect(buffer.lineForRow(2)).toBe ' iitems.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".indent()", -> - describe "when the selection is empty", -> - describe "when autoIndent is disabled", -> - describe "if 'softTabs' is true (the default)", -> - it "inserts 'tabLength' spaces into the buffer", -> - tabRegex = new RegExp("^[ ]{#{editor.getTabLength()}}") - expect(buffer.lineForRow(0)).not.toMatch(tabRegex) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(tabRegex) - - it "respects the tab stops when cursor is in the middle of a tab", -> - editor.setTabLength(4) - buffer.insert([12, 2], "\n ") - editor.setCursorBufferPosition [13, 1] - editor.indent() - expect(buffer.lineForRow(13)).toMatch /^\s+$/ - expect(buffer.lineForRow(13).length).toBe 4 - expect(editor.getCursorBufferPosition()).toEqual [13, 4] - - buffer.insert([13, 0], " ") - editor.setCursorBufferPosition [13, 6] - editor.indent() - expect(buffer.lineForRow(13).length).toBe 8 - - describe "if 'softTabs' is false", -> - it "insert a \t into the buffer", -> - editor.setSoftTabs(false) - expect(buffer.lineForRow(0)).not.toMatch(/^\t/) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t/) - - describe "when autoIndent is enabled", -> - describe "when the cursor's column is less than the suggested level of indentation", -> - describe "when 'softTabs' is true (the default)", -> - it "moves the cursor to the end of the leading whitespace and inserts enough whitespace to bring the line to the suggested level of indentation", -> - buffer.insert([5, 0], " \n") - editor.setCursorBufferPosition [5, 0] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(5)).toMatch /^\s+$/ - expect(buffer.lineForRow(5).length).toBe 6 - expect(editor.getCursorBufferPosition()).toEqual [5, 6] - - it "respects the tab stops when cursor is in the middle of a tab", -> - editor.setTabLength(4) - buffer.insert([12, 2], "\n ") - editor.setCursorBufferPosition [13, 1] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(13)).toMatch /^\s+$/ - expect(buffer.lineForRow(13).length).toBe 4 - expect(editor.getCursorBufferPosition()).toEqual [13, 4] - - buffer.insert([13, 0], " ") - editor.setCursorBufferPosition [13, 6] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(13).length).toBe 8 - - describe "when 'softTabs' is false", -> - it "moves the cursor to the end of the leading whitespace and inserts enough tabs to bring the line to the suggested level of indentation", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - buffer.insert([5, 0], "\t\n") - editor.setCursorBufferPosition [5, 0] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(5)).toMatch /^\t\t\t$/ - expect(editor.getCursorBufferPosition()).toEqual [5, 3] - - describe "when the difference between the suggested level of indentation and the current level of indentation is greater than 0 but less than 1", -> - it "inserts one tab", -> - editor.setSoftTabs(false) - buffer.setText(" \ntest") - editor.setCursorBufferPosition [1, 0] - - editor.indent(autoIndent: true) - expect(buffer.lineForRow(1)).toBe '\ttest' - expect(editor.getCursorBufferPosition()).toEqual [1, 1] - - describe "when the line's indent level is greater than the suggested level of indentation", -> - describe "when 'softTabs' is true (the default)", -> - it "moves the cursor to the end of the leading whitespace and inserts 'tabLength' spaces into the buffer", -> - buffer.insert([7, 0], " \n") - editor.setCursorBufferPosition [7, 2] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(7)).toMatch /^\s+$/ - expect(buffer.lineForRow(7).length).toBe 8 - expect(editor.getCursorBufferPosition()).toEqual [7, 8] - - describe "when 'softTabs' is false", -> - it "moves the cursor to the end of the leading whitespace and inserts \t into the buffer", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - buffer.insert([7, 0], "\t\t\t\n") - editor.setCursorBufferPosition [7, 1] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(7)).toMatch /^\t\t\t\t$/ - expect(editor.getCursorBufferPosition()).toEqual [7, 4] - - describe "when the selection is not empty", -> - it "indents the selected lines", -> - editor.setSelectedBufferRange([[0, 0], [10, 0]]) - selection = editor.getLastSelection() - spyOn(selection, "indentSelectedRows") - editor.indent() - expect(selection.indentSelectedRows).toHaveBeenCalled() - - describe "if editor.softTabs is false", -> - it "inserts a tab character into the buffer", -> - editor.setSoftTabs(false) - expect(buffer.lineForRow(0)).not.toMatch(/^\t/) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t/) - expect(editor.getCursorBufferPosition()).toEqual [0, 1] - expect(editor.getCursorScreenPosition()).toEqual [0, editor.getTabLength()] - - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t\t/) - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - expect(editor.getCursorScreenPosition()).toEqual [0, editor.getTabLength() * 2] - - describe "clipboard operations", -> - describe ".cutSelectedText()", -> - it "removes the selected text from the buffer and places it on the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - editor.cutSelectedText() - expect(buffer.lineForRow(0)).toBe "var = function () {" - expect(buffer.lineForRow(1)).toBe " var = function(items) {" - expect(clipboard.readText()).toBe 'quicksort\nsort' - - describe "when no text is selected", -> - beforeEach -> - editor.setSelectedBufferRanges([ - [[0, 0], [0, 0]], - [[5, 0], [5, 0]], - ]) - - it "cuts the lines on which there are cursors", -> - editor.cutSelectedText() - expect(buffer.getLineCount()).toBe(11) - expect(buffer.lineForRow(1)).toBe(" if (items.length <= 1) return items;") - expect(buffer.lineForRow(4)).toBe(" current < pivot ? left.push(current) : right.push(current);") - expect(atom.clipboard.read()).toEqual """ - var quicksort = function () { - - current = items.shift(); - - """ - - describe "when many selections get added in shuffle order", -> - it "cuts them in order", -> - editor.setSelectedBufferRanges([ - [[2, 8], [2, 13]] - [[0, 4], [0, 13]], - [[1, 6], [1, 10]], - ]) - editor.cutSelectedText() - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe ".cutToEndOfLine()", -> - describe "when soft wrap is on", -> - it "cuts up to the end of the line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(25) - editor.setCursorScreenPosition([2, 6]) - editor.cutToEndOfLine() - expect(editor.lineTextForScreenRow(2)).toBe ' var function(items) {' - - describe "when soft wrap is off", -> - describe "when nothing is selected", -> - it "cuts up to the end of the line", -> - editor.setCursorBufferPosition([2, 20]) - editor.addCursorAtBufferPosition([3, 20]) - editor.cutToEndOfLine() - expect(buffer.lineForRow(2)).toBe ' if (items.length' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) return items;\ns.shift(), current, left = [], right = [];' - - describe "when text is selected", -> - it "only cuts the selected text, not to the end of the line", -> - editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) - - editor.cutToEndOfLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.lengthurn items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) ret\ns.shift(), current, left = [], right = [];' - - describe ".cutToEndOfBufferLine()", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - - describe "when nothing is selected", -> - it "cuts up to the end of the buffer line", -> - editor.setCursorBufferPosition([2, 20]) - editor.addCursorAtBufferPosition([3, 20]) - - editor.cutToEndOfBufferLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.length' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) return items;\ns.shift(), current, left = [], right = [];' - - describe "when text is selected", -> - it "only cuts the selected text, not to the end of the buffer line", -> - editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) - - editor.cutToEndOfBufferLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.lengthurn items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) ret\ns.shift(), current, left = [], right = [];' - - describe ".copySelectedText()", -> - it "copies selected text onto the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) - - editor.copySelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(clipboard.readText()).toBe 'quicksort\nsort\nitems' - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe "when no text is selected", -> - beforeEach -> - editor.setSelectedBufferRanges([ - [[1, 5], [1, 5]], - [[5, 8], [5, 8]] - ]) - - it "copies the lines on which there are cursors", -> - editor.copySelectedText() - expect(atom.clipboard.read()).toEqual([ - " var sort = function(items) {\n" - " current = items.shift();\n" - ].join("\n")) - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 5], [1, 5]], - [[5, 8], [5, 8]] - ]) - - describe "when many selections get added in shuffle order", -> - it "copies them in order", -> - editor.setSelectedBufferRanges([ - [[2, 8], [2, 13]] - [[0, 4], [0, 13]], - [[1, 6], [1, 10]], - ]) - editor.copySelectedText() - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe ".copyOnlySelectedText()", -> - describe "when thee are multiple selections", -> - it "copies selected text onto the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) - - editor.copyOnlySelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(clipboard.readText()).toBe 'quicksort\nsort\nitems' - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe "when no text is selected", -> - it "does not copy anything", -> - editor.setCursorBufferPosition([1, 5]) - editor.copyOnlySelectedText() - expect(atom.clipboard.read()).toEqual "initial clipboard content" - - describe ".pasteText()", -> - copyText = (text, {startColumn, textEditor}={}) -> - startColumn ?= 0 - textEditor ?= editor - textEditor.setCursorBufferPosition([0, 0]) - textEditor.insertText(text) - numberOfNewlines = text.match(/\n/g)?.length - endColumn = text.match(/[^\n]*$/)[0]?.length - textEditor.getLastSelection().setBufferRange([[0, startColumn], [numberOfNewlines, endColumn]]) - textEditor.cutSelectedText() - - it "pastes text into the buffer", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - atom.clipboard.write('first') - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var first = function () {" - expect(editor.lineTextForBufferRow(1)).toBe " var first = function(items) {" - - it "notifies ::onWillInsertText observers", -> - insertedStrings = [] - editor.onWillInsertText ({text, cancel}) -> - insertedStrings.push(text) - cancel() - - atom.clipboard.write("hello") - editor.pasteText() - - expect(insertedStrings).toEqual ["hello"] - - it "notifies ::onDidInsertText observers", -> - insertedStrings = [] - editor.onDidInsertText ({text, range}) -> - insertedStrings.push(text) - - atom.clipboard.write("hello") - editor.pasteText() - - expect(insertedStrings).toEqual ["hello"] - - describe "when `autoIndentOnPaste` is true", -> - beforeEach -> - editor.update({autoIndentOnPaste: true}) - - describe "when pasting multiple lines before any non-whitespace characters", -> - it "auto-indents the lines spanned by the pasted text, based on the first pasted line", -> - atom.clipboard.write("a(x);\n b(x);\n c(x);\n", indentBasis: 0) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - # Adjust the indentation of the pasted lines while preserving - # their indentation relative to each other. Also preserve the - # indentation of the following line. - expect(editor.lineTextForBufferRow(5)).toBe " a(x);" - expect(editor.lineTextForBufferRow(6)).toBe " b(x);" - expect(editor.lineTextForBufferRow(7)).toBe " c(x);" - expect(editor.lineTextForBufferRow(8)).toBe " current = items.shift();" - - it "auto-indents lines with a mix of hard tabs and spaces without removing spaces", -> - editor.setSoftTabs(false) - expect(editor.indentationForBufferRow(5)).toBe(3) - - atom.clipboard.write("/**\n\t * testing\n\t * indent\n\t **/\n", indentBasis: 1) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - # Do not lose the alignment spaces - expect(editor.lineTextForBufferRow(5)).toBe("\t\t\t/**") - expect(editor.lineTextForBufferRow(6)).toBe("\t\t\t * testing") - expect(editor.lineTextForBufferRow(7)).toBe("\t\t\t * indent") - expect(editor.lineTextForBufferRow(8)).toBe("\t\t\t **/") - - describe "when pasting line(s) above a line that matches the decreaseIndentPattern", -> - it "auto-indents based on the pasted line(s) only", -> - atom.clipboard.write("a(x);\n b(x);\n c(x);\n", indentBasis: 0) - editor.setCursorBufferPosition([7, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(7)).toBe " a(x);" - expect(editor.lineTextForBufferRow(8)).toBe " b(x);" - expect(editor.lineTextForBufferRow(9)).toBe " c(x);" - expect(editor.lineTextForBufferRow(10)).toBe " }" - - describe "when pasting a line of text without line ending", -> - it "does not auto-indent the text", -> - atom.clipboard.write("a(x);", indentBasis: 0) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(5)).toBe "a(x); current = items.shift();" - expect(editor.lineTextForBufferRow(6)).toBe " current < pivot ? left.push(current) : right.push(current);" - - describe "when pasting on a line after non-whitespace characters", -> - it "does not auto-indent the affected line", -> - # Before the paste, the indentation is non-standard. - editor.setText """ - if (x) { - y(); - } - """ - - atom.clipboard.write(" z();\n h();") - editor.setCursorBufferPosition([1, Infinity]) - - # The indentation of the non-standard line is unchanged. - editor.pasteText() - expect(editor.lineTextForBufferRow(1)).toBe(" y(); z();") - expect(editor.lineTextForBufferRow(2)).toBe(" h();") - - describe "when `autoIndentOnPaste` is false", -> - beforeEach -> - editor.update({autoIndentOnPaste: false}) - - describe "when the cursor is indented further than the original copied text", -> - it "increases the indentation of the copied lines to match", -> - editor.setSelectedBufferRange([[1, 2], [3, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([5, 6]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(5)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is indented less far than the original copied text", -> - it "decreases the indentation of the copied lines to match", -> - editor.setSelectedBufferRange([[6, 6], [8, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([1, 2]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(1)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(2)).toBe "}" - - describe "when the first copied line has leading whitespace", -> - it "preserves the line's leading whitespace", -> - editor.setSelectedBufferRange([[4, 0], [6, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([0, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(0)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(1)).toBe " current = items.shift();" - - describe 'when the clipboard has many selections', -> - beforeEach -> - editor.update({autoIndentOnPaste: false}) - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - editor.copySelectedText() - - it "pastes each selection in order separately into the buffer", -> - editor.setSelectedBufferRanges([ - [[1, 6], [1, 10]] - [[0, 4], [0, 13]], - ]) - - editor.moveRight() - editor.insertText("_") - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort_quicksort = function () {" - expect(editor.lineTextForBufferRow(1)).toBe " var sort_sort = function(items) {" - - describe 'and the selections count does not match', -> - beforeEach -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]]]) - - it "pastes the whole text into the buffer", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort" - expect(editor.lineTextForBufferRow(1)).toBe "sort = function () {" - - describe "when a full line was cut", -> - beforeEach -> - editor.setCursorBufferPosition([2, 13]) - editor.cutSelectedText() - editor.setCursorBufferPosition([2, 13]) - - it "pastes the line above the cursor and retains the cursor's column", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(2)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(3)).toBe(" var pivot = items.shift(), current, left = [], right = [];") - expect(editor.getCursorBufferPosition()).toEqual([3, 13]) - - describe "when a full line was copied", -> - beforeEach -> - editor.setCursorBufferPosition([2, 13]) - editor.copySelectedText() - - describe "when there is a selection", -> - it "overwrites the selection as with any copied text", -> - editor.setSelectedBufferRange([[1, 2], [1, Infinity]]) - editor.pasteText() - expect(editor.lineTextForBufferRow(1)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(2)).toBe("") - expect(editor.lineTextForBufferRow(3)).toBe(" if (items.length <= 1) return items;") - expect(editor.getCursorBufferPosition()).toEqual([2, 0]) - - describe "when there is no selection", -> - it "pastes the line above the cursor and retains the cursor's column", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(2)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(3)).toBe(" if (items.length <= 1) return items;") - expect(editor.getCursorBufferPosition()).toEqual([3, 13]) - - describe ".indentSelectedRows()", -> - describe "when nothing is selected", -> - describe "when softTabs is enabled", -> - it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0, 3], [0, 3]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 3 + editor.getTabLength()], [0, 3 + editor.getTabLength()]] - - describe "when softTabs is disabled", -> - it "indents line and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0, 3], [0, 3]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 3 + 1], [0, 3 + 1]] - - describe "when one line is selected", -> - describe "when softTabs is enabled", -> - it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0, 4], [0, 14]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "#{editor.getTabText()}var quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 4 + editor.getTabLength()], [0, 14 + editor.getTabLength()]] - - describe "when softTabs is disabled", -> - it "indents line and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0, 4], [0, 14]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 4 + 1], [0, 14 + 1]] - - describe "when multiple lines are selected", -> - describe "when softTabs is enabled", -> - it "indents selected lines (that are not empty) and retains selection", -> - editor.setSelectedBufferRange([[9, 1], [11, 15]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe " };" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + editor.getTabLength()], [11, 15 + editor.getTabLength()]] - - it "does not indent the last row if the selection ends at column 0", -> - editor.setSelectedBufferRange([[9, 1], [11, 0]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe " };" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + editor.getTabLength()], [11, 0]] - - describe "when softTabs is disabled", -> - it "indents selected lines (that are not empty) and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[9, 1], [11, 15]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe "\t\t};" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe "\t\treturn sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + 1], [11, 15 + 1]] - - describe ".outdentSelectedRows()", -> - describe "when nothing is selected", -> - it "outdents line and retains selection", -> - editor.setSelectedBufferRange([[1, 3], [1, 3]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(editor.getSelectedBufferRange()).toEqual [[1, 3 - editor.getTabLength()], [1, 3 - editor.getTabLength()]] - - it "outdents when indent is less than a tab length", -> - editor.insertText(' ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents a single hard tab when indent is multiple hard tabs and and the session is using soft tabs", -> - editor.insertText('\t\t') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents when a mix of hard tabs and soft tabs are used", -> - editor.insertText('\t ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents only up to the first non-space non-tab character", -> - editor.insertText(' \tfoo\t ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tfoo\t var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "foo\t var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "foo\t var quicksort = function () {" - - describe "when one line is selected", -> - it "outdents line and retains editor", -> - editor.setSelectedBufferRange([[1, 4], [1, 14]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(editor.getSelectedBufferRange()).toEqual [[1, 4 - editor.getTabLength()], [1, 14 - editor.getTabLength()]] - - describe "when multiple lines are selected", -> - it "outdents selected lines and retains editor", -> - editor.setSelectedBufferRange([[0, 1], [3, 15]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 15 - editor.getTabLength()]] - - it "does not outdent the last line of the selection if it ends at column 0", -> - editor.setSelectedBufferRange([[0, 1], [3, 0]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 0]] - - describe ".autoIndentSelectedRows", -> - it "auto-indents the selection", -> - editor.setCursorBufferPosition([2, 0]) - editor.insertText("function() {\ninside=true\n}\n i=1\n") - editor.getLastSelection().setBufferRange([[2, 0], [6, 0]]) - editor.autoIndentSelectedRows() - - expect(editor.lineTextForBufferRow(2)).toBe " function() {" - expect(editor.lineTextForBufferRow(3)).toBe " inside=true" - expect(editor.lineTextForBufferRow(4)).toBe " }" - expect(editor.lineTextForBufferRow(5)).toBe " i=1" - - describe ".toggleLineCommentsInSelection()", -> - it "toggles comments on the selected lines", -> - editor.setSelectedBufferRange([[4, 5], [7, 5]]) - editor.toggleLineCommentsInSelection() - - expect(buffer.lineForRow(4)).toBe " // while(items.length > 0) {" - expect(buffer.lineForRow(5)).toBe " // current = items.shift();" - expect(buffer.lineForRow(6)).toBe " // current < pivot ? left.push(current) : right.push(current);" - expect(buffer.lineForRow(7)).toBe " // }" - expect(editor.getSelectedBufferRange()).toEqual [[4, 8], [7, 8]] - - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(4)).toBe " while(items.length > 0) {" - expect(buffer.lineForRow(5)).toBe " current = items.shift();" - expect(buffer.lineForRow(6)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(buffer.lineForRow(7)).toBe " }" - - it "does not comment the last line of a non-empty selection if it ends at column 0", -> - editor.setSelectedBufferRange([[4, 5], [7, 0]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(4)).toBe " // while(items.length > 0) {" - expect(buffer.lineForRow(5)).toBe " // current = items.shift();" - expect(buffer.lineForRow(6)).toBe " // current < pivot ? left.push(current) : right.push(current);" - expect(buffer.lineForRow(7)).toBe " }" - - it "uncomments lines if all lines match the comment regex", -> - editor.setSelectedBufferRange([[0, 0], [0, 1]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// // var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "// var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe "// if (items.length <= 1) return items;" - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - - editor.setSelectedBufferRange([[0, 0], [0, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "uncomments commented lines separated by an empty line", -> - editor.setSelectedBufferRange([[0, 0], [1, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "// var sort = function(items) {" - - buffer.insert([0, Infinity], '\n') - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "" - expect(buffer.lineForRow(2)).toBe " var sort = function(items) {" - - it "preserves selection emptiness", -> - editor.setCursorBufferPosition([4, 0]) - editor.toggleLineCommentsInSelection() - expect(editor.getLastSelection().isEmpty()).toBeTruthy() - - it "does not explode if the current language mode has no comment regex", -> - editor = new TextEditor(buffer: new TextBuffer(text: 'hello')) - editor.setSelectedBufferRange([[0, 0], [0, 5]]) - editor.toggleLineCommentsInSelection() - expect(editor.lineTextForBufferRow(0)).toBe "hello" - - it "does nothing for empty lines and null grammar", -> - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('text.plain.null-grammar')) - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - expect(editor.buffer.lineForRow(10)).toBe "" - - it "uncomments when the line lacks the trailing whitespace in the comment regex", -> - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - - expect(buffer.lineForRow(10)).toBe "// " - expect(editor.getSelectedBufferRange()).toEqual [[10, 3], [10, 3]] - editor.backspace() - expect(buffer.lineForRow(10)).toBe "//" - - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(10)).toBe "" - expect(editor.getSelectedBufferRange()).toEqual [[10, 0], [10, 0]] - - it "uncomments when the line has leading whitespace", -> - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - - expect(buffer.lineForRow(10)).toBe "// " - editor.moveToBeginningOfLine() - editor.insertText(" ") - editor.setSelectedBufferRange([[10, 0], [10, 0]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(10)).toBe " " - - describe ".undo() and .redo()", -> - it "undoes/redoes the last change", -> - editor.insertText("foo") - editor.undo() - expect(buffer.lineForRow(0)).not.toContain "foo" - - editor.redo() - expect(buffer.lineForRow(0)).toContain "foo" - - it "batches the undo / redo of changes caused by multiple cursors", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([1, 0]) - - editor.insertText("foo") - editor.backspace() - - expect(buffer.lineForRow(0)).toContain "fovar" - expect(buffer.lineForRow(1)).toContain "fo " - - editor.undo() - - expect(buffer.lineForRow(0)).toContain "foo" - expect(buffer.lineForRow(1)).toContain "foo" - - editor.redo() - - expect(buffer.lineForRow(0)).not.toContain "foo" - expect(buffer.lineForRow(0)).toContain "fovar" - - it "restores cursors and selections to their states before and after undone and redone changes", -> - editor.setSelectedBufferRanges([ - [[0, 0], [0, 0]], - [[1, 0], [1, 3]], - ]) - editor.insertText("abc") - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 3]], - [[1, 3], [1, 3]] - ] - - editor.setCursorBufferPosition([0, 0]) - editor.setSelectedBufferRanges([ - [[2, 0], [2, 0]], - [[3, 0], [3, 0]], - [[4, 0], [4, 3]], - ]) - editor.insertText("def") - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 3], [2, 3]], - [[3, 3], [3, 3]] - [[4, 3], [4, 3]] - ] - - editor.setCursorBufferPosition([0, 0]) - editor.undo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 0], [2, 0]], - [[3, 0], [3, 0]], - [[4, 0], [4, 3]], - ] - - editor.undo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 0], [0, 0]], - [[1, 0], [1, 3]] - ] - - editor.redo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 3]], - [[1, 3], [1, 3]] - ] - - editor.redo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 3], [2, 3]], - [[3, 3], [3, 3]] - [[4, 3], [4, 3]] - ] - - it "restores the selected ranges after undo and redo", -> - editor.setSelectedBufferRanges([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) - editor.delete() - editor.delete() - - selections = editor.getSelections() - expect(buffer.lineForRow(1)).toBe ' var = function( {' - - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 17], [1, 17]]] - - editor.undo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] - - editor.undo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 10]], [[1, 22], [1, 27]]] - - editor.redo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] - - xit "restores folds after undo and redo", -> - editor.foldBufferRow(1) - editor.setSelectedBufferRange([[1, 0], [10, Infinity]], preserveFolds: true) - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - - editor.insertText """ - \ // testing - function foo() { - return 1 + 2; - } - """ - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - editor.foldBufferRow(2) - - editor.undo() - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - editor.redo() - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - - describe "::transact", -> - it "restores the selection when the transaction is undone/redone", -> - buffer.setText('1234') - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - - editor.transact -> - editor.delete() - editor.moveToEndOfLine() - editor.insertText('5') - expect(buffer.getText()).toBe '145' - - editor.undo() - expect(buffer.getText()).toBe '1234' - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [0, 3]] - - editor.redo() - expect(buffer.getText()).toBe '145' - expect(editor.getSelectedBufferRange()).toEqual [[0, 3], [0, 3]] - - describe "when the buffer is changed (via its direct api, rather than via than edit session)", -> - it "moves the cursor so it is in the same relative position of the buffer", -> - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - editor.addCursorAtScreenPosition([0, 5]) - editor.addCursorAtScreenPosition([1, 0]) - [cursor1, cursor2, cursor3] = editor.getCursors() - - buffer.insert([0, 1], 'abc') - - expect(cursor1.getScreenPosition()).toEqual [0, 0] - expect(cursor2.getScreenPosition()).toEqual [0, 8] - expect(cursor3.getScreenPosition()).toEqual [1, 0] - - it "does not destroy cursors or selections when a change encompasses them", -> - cursor = editor.getLastCursor() - cursor.setBufferPosition [3, 3] - editor.buffer.delete([[3, 1], [3, 5]]) - expect(cursor.getBufferPosition()).toEqual [3, 1] - expect(editor.getCursors().indexOf(cursor)).not.toBe -1 - - selection = editor.getLastSelection() - selection.setBufferRange [[3, 5], [3, 10]] - editor.buffer.delete [[3, 3], [3, 8]] - expect(selection.getBufferRange()).toEqual [[3, 3], [3, 5]] - expect(editor.getSelections().indexOf(selection)).not.toBe -1 - - it "merges cursors when the change causes them to overlap", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 2]) - editor.addCursorAtScreenPosition([1, 2]) - - [cursor1, cursor2, cursor3] = editor.getCursors() - expect(editor.getCursors().length).toBe 3 - - buffer.delete([[0, 0], [0, 2]]) - - expect(editor.getCursors().length).toBe 2 - expect(editor.getCursors()).toEqual [cursor1, cursor3] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor3.getBufferPosition()).toEqual [1, 2] - - describe ".moveSelectionLeft()", -> - it "moves one active selection on one line one column to the left", -> - editor.setSelectedBufferRange [[0, 4], [0, 13]] - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.moveSelectionLeft() - - expect(editor.getSelectedText()).toBe 'quicksort' - expect(editor.getSelectedBufferRange()).toEqual [[0, 3], [0, 12]] - - it "moves multiple active selections on one line one column to the left", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 3], [0, 12]], [[0, 15], [0, 23]]] - - it "moves multiple active selections on multiple lines one column to the left", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 3], [0, 12]], [[1, 5], [1, 9]]] - - describe "when a selection is at the first column of a line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe ' v' - - editor.moveSelectionLeft() - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe ' v' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]], [[1, 0], [1, 3]]] - - describe "when multiple selections are active on one line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe 'quicksort' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe 'quicksort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]], [[0, 4], [0, 13]]] - - describe ".moveSelectionRight()", -> - it "moves one active selection on one line one column to the right", -> - editor.setSelectedBufferRange [[0, 4], [0, 13]] - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.moveSelectionRight() - - expect(editor.getSelectedText()).toBe 'quicksort' - expect(editor.getSelectedBufferRange()).toEqual [[0, 5], [0, 14]] - - it "moves multiple active selections on one line one column to the right", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 5], [0, 14]], [[0, 17], [0, 25]]] - - it "moves multiple active selections on multiple lines one column to the right", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 5], [0, 14]], [[1, 7], [1, 11]]] - - describe "when a selection is at the last column of a line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'items;' - expect(selections[1].getText()).toBe 'shift();' - - editor.moveSelectionRight() - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'items;' - expect(selections[1].getText()).toBe 'shift();' - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 34], [2, 40]], [[5, 22], [5, 30]]] - - describe "when multiple selections are active on one line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'return' - expect(selections[1].getText()).toBe 'items;' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'return' - expect(selections[1].getText()).toBe 'items;' - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 27], [2, 33]], [[2, 34], [2, 40]]] - - describe 'reading text', -> - it '.lineTextForScreenRow(row)', -> - editor.foldBufferRow(4) - expect(editor.lineTextForScreenRow(5)).toEqual ' return sort(left).concat(pivot).concat(sort(right));' - expect(editor.lineTextForScreenRow(9)).toEqual '};' - expect(editor.lineTextForScreenRow(10)).toBeUndefined() - - describe ".deleteLine()", -> - it "deletes the first line when the cursor is there", -> - editor.getLastCursor().moveToTop() - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line1) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "deletes the last line when the cursor is there", -> - count = buffer.getLineCount() - secondToLastLine = buffer.lineForRow(count - 2) - expect(buffer.lineForRow(count - 1)).not.toBe(secondToLastLine) - editor.getLastCursor().moveToBottom() - editor.deleteLine() - newCount = buffer.getLineCount() - expect(buffer.lineForRow(newCount - 1)).toBe(secondToLastLine) - expect(newCount).toBe(count - 1) - - it "deletes whole lines when partial lines are selected", -> - editor.setSelectedBufferRange([[0, 2], [1, 2]]) - line2 = buffer.lineForRow(2) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line2) - expect(buffer.lineForRow(1)).not.toBe(line2) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line2) - expect(buffer.getLineCount()).toBe(count - 2) - - it "deletes a line only once when multiple selections are on the same line", -> - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - editor.setSelectedBufferRanges([ - [[0, 1], [0, 2]], - [[0, 4], [0, 5]] - ]) - expect(buffer.lineForRow(0)).not.toBe(line1) - - editor.deleteLine() - - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "only deletes first line if only newline is selected on second line", -> - editor.setSelectedBufferRange([[0, 2], [1, 0]]) - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line1) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "deletes the entire region when invoke on a folded region", -> - editor.foldBufferRow(1) - editor.getLastCursor().moveToTop() - editor.getLastCursor().moveDown() - expect(buffer.getLineCount()).toBe(13) - editor.deleteLine() - expect(buffer.getLineCount()).toBe(4) - - it "deletes the entire file from the bottom up", -> - count = buffer.getLineCount() - expect(count).toBeGreaterThan(0) - for [0...count] - editor.getLastCursor().moveToBottom() - editor.deleteLine() - expect(buffer.getLineCount()).toBe(1) - expect(buffer.getText()).toBe('') - - it "deletes the entire file from the top down", -> - count = buffer.getLineCount() - expect(count).toBeGreaterThan(0) - for [0...count] - editor.getLastCursor().moveToTop() - editor.deleteLine() - expect(buffer.getLineCount()).toBe(1) - expect(buffer.getText()).toBe('') - - describe "when soft wrap is enabled", -> - it "deletes the entire line that the cursor is on", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - editor.setCursorBufferPosition([6]) - - line7 = buffer.lineForRow(7) - count = buffer.getLineCount() - expect(buffer.lineForRow(6)).not.toBe(line7) - editor.deleteLine() - expect(buffer.lineForRow(6)).toBe(line7) - expect(buffer.getLineCount()).toBe(count - 1) - - describe "when the line being deleted precedes a fold, and the command is undone", -> - it "restores the line and preserves the fold", -> - editor.setCursorBufferPosition([4]) - editor.foldCurrentRow() - expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() - editor.setCursorBufferPosition([3]) - editor.deleteLine() - expect(editor.isFoldedAtScreenRow(3)).toBeTruthy() - expect(buffer.lineForRow(3)).toBe ' while(items.length > 0) {' - editor.undo() - expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() - expect(buffer.lineForRow(3)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - - describe ".replaceSelectedText(options, fn)", -> - describe "when no text is selected", -> - it "inserts the text returned from the function at the cursor position", -> - editor.replaceSelectedText {}, -> '123' - expect(buffer.lineForRow(0)).toBe '123var quicksort = function () {' - - editor.setCursorBufferPosition([0]) - editor.replaceSelectedText {selectWordIfEmpty: true}, -> 'var' - expect(buffer.lineForRow(0)).toBe 'var quicksort = function () {' - - editor.setCursorBufferPosition([10]) - editor.replaceSelectedText null, -> '' - expect(buffer.lineForRow(10)).toBe '' - - describe "when text is selected", -> - it "replaces the selected text with the text returned from the function", -> - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - editor.replaceSelectedText {}, -> 'ia' - expect(buffer.lineForRow(0)).toBe 'via quicksort = function () {' - - it "replaces the selected text and selects the replacement text", -> - editor.setSelectedBufferRange([[0, 4], [0, 9]]) - editor.replaceSelectedText {}, -> 'whatnot' - expect(buffer.lineForRow(0)).toBe 'var whatnotsort = function () {' - expect(editor.getSelectedBufferRange()).toEqual [[0, 4], [0, 11]] - - describe ".transpose()", -> - it "swaps two characters", -> - editor.buffer.setText("abc") - editor.setCursorScreenPosition([0, 1]) - editor.transpose() - expect(editor.lineTextForBufferRow(0)).toBe 'bac' - - it "reverses a selection", -> - editor.buffer.setText("xabcz") - editor.setSelectedBufferRange([[0, 1], [0, 4]]) - editor.transpose() - expect(editor.lineTextForBufferRow(0)).toBe 'xcbaz' - - describe ".upperCase()", -> - describe "when there is no selection", -> - it "upper cases the current word", -> - editor.buffer.setText("aBc") - editor.setCursorScreenPosition([0, 1]) - editor.upperCase() - expect(editor.lineTextForBufferRow(0)).toBe 'ABC' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 3]] - - describe "when there is a selection", -> - it "upper cases the current selection", -> - editor.buffer.setText("abc") - editor.setSelectedBufferRange([[0, 0], [0, 2]]) - editor.upperCase() - expect(editor.lineTextForBufferRow(0)).toBe 'ABc' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] - - describe ".lowerCase()", -> - describe "when there is no selection", -> - it "lower cases the current word", -> - editor.buffer.setText("aBC") - editor.setCursorScreenPosition([0, 1]) - editor.lowerCase() - expect(editor.lineTextForBufferRow(0)).toBe 'abc' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 3]] - - describe "when there is a selection", -> - it "lower cases the current selection", -> - editor.buffer.setText("ABC") - editor.setSelectedBufferRange([[0, 0], [0, 2]]) - editor.lowerCase() - expect(editor.lineTextForBufferRow(0)).toBe 'abC' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] - - describe '.setTabLength(tabLength)', -> - it 'clips atomic soft tabs to the given tab length', -> - expect(editor.getTabLength()).toBe 2 - expect(editor.clipScreenPosition([5, 1], clipDirection: 'forward')).toEqual([5, 2]) - - editor.setTabLength(6) - expect(editor.getTabLength()).toBe 6 - expect(editor.clipScreenPosition([5, 1], clipDirection: 'forward')).toEqual([5, 6]) - - changeHandler = jasmine.createSpy('changeHandler') - editor.onDidChange(changeHandler) - editor.setTabLength(6) - expect(changeHandler).not.toHaveBeenCalled() - - it 'does not change its tab length when the given tab length is null', -> - editor.setTabLength(4) - editor.setTabLength(null) - expect(editor.getTabLength()).toBe(4) - - describe ".indentLevelForLine(line)", -> - it "returns the indent level when the line has only leading whitespace", -> - expect(editor.indentLevelForLine(" hello")).toBe(2) - expect(editor.indentLevelForLine(" hello")).toBe(1.5) - - it "returns the indent level when the line has only leading tabs", -> - expect(editor.indentLevelForLine("\t\thello")).toBe(2) - - it "returns the indent level based on the character starting the line when the leading whitespace contains both spaces and tabs", -> - expect(editor.indentLevelForLine("\t hello")).toBe(2) - expect(editor.indentLevelForLine(" \thello")).toBe(2) - expect(editor.indentLevelForLine(" \t hello")).toBe(2.5) - expect(editor.indentLevelForLine(" \t \thello")).toBe(4) - expect(editor.indentLevelForLine(" \t \thello")).toBe(4) - expect(editor.indentLevelForLine(" \t \t hello")).toBe(4.5) - - describe "when a better-matched grammar is added to syntax", -> - it "switches to the better-matched grammar and re-tokenizes the buffer", -> - editor.destroy() - - jsGrammar = atom.grammars.selectGrammar('a.js') - atom.grammars.removeGrammar(jsGrammar) - - waitsForPromise -> - atom.workspace.open('sample.js', autoIndent: false).then (o) -> editor = o - - runs -> - expect(editor.getGrammar()).toBe atom.grammars.nullGrammar - expect(editor.tokensForScreenRow(0).length).toBe(1) - - atom.grammars.addGrammar(jsGrammar) - expect(editor.getGrammar()).toBe jsGrammar - expect(editor.tokensForScreenRow(0).length).toBeGreaterThan 1 - - describe "editor.autoIndent", -> - describe "when editor.autoIndent is false (default)", -> - describe "when `indent` is triggered", -> - it "does not auto-indent the line", -> - editor.setCursorBufferPosition([1, 30]) - editor.insertText("\n ") - expect(editor.lineTextForBufferRow(2)).toBe " " - - editor.update({autoIndent: false}) - editor.indent() - expect(editor.lineTextForBufferRow(2)).toBe " " - - describe "when editor.autoIndent is true", -> - beforeEach -> - editor.update({autoIndent: true}) - - describe "when `indent` is triggered", -> - it "auto-indents the line", -> - editor.setCursorBufferPosition([1, 30]) - editor.insertText("\n ") - expect(editor.lineTextForBufferRow(2)).toBe " " - - editor.update({autoIndent: true}) - editor.indent() - expect(editor.lineTextForBufferRow(2)).toBe " " - - describe "when a newline is added", -> - describe "when the line preceding the newline adds a new level of indentation", -> - it "indents the newline to one additional level of indentation beyond the preceding line", -> - editor.setCursorBufferPosition([1, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - - describe "when the line preceding the newline doesn't add a level of indentation", -> - it "indents the new line to the same level as the preceding line", -> - editor.setCursorBufferPosition([5, 14]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(6)).toBe editor.indentationForBufferRow(5) - - describe "when the line preceding the newline is a comment", -> - it "maintains the indent of the commented line", -> - editor.setCursorBufferPosition([0, 0]) - editor.insertText(' //') - editor.setCursorBufferPosition([0, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(1)).toBe 2 - - describe "when the line preceding the newline contains only whitespace", -> - it "bases the new line's indentation on only the preceding line", -> - editor.setCursorBufferPosition([6, Infinity]) - editor.insertText("\n ") - expect(editor.getCursorBufferPosition()).toEqual([7, 2]) - - editor.insertNewline() - expect(editor.lineTextForBufferRow(8)).toBe(" ") - - it "does not indent the line preceding the newline", -> - editor.setCursorBufferPosition([2, 0]) - editor.insertText(' var this-line-should-be-indented-more\n') - expect(editor.indentationForBufferRow(1)).toBe 1 - - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([2, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 1 - - describe "when the cursor is before whitespace", -> - it "retains the whitespace following the cursor on the new line", -> - editor.setText(" var sort = function() {}") - editor.setCursorScreenPosition([0, 12]) - editor.insertNewline() - - expect(buffer.lineForRow(0)).toBe ' var sort =' - expect(buffer.lineForRow(1)).toBe ' function() {}' - expect(editor.getCursorScreenPosition()).toEqual [1, 2] - - describe "when inserted text matches a decrease indent pattern", -> - describe "when the preceding line matches an increase indent pattern", -> - it "decreases the indentation to match that of the preceding line", -> - editor.setCursorBufferPosition([1, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - editor.insertText('}') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) - - describe "when the preceding line doesn't match an increase indent pattern", -> - it "decreases the indentation to be one level below that of the preceding line", -> - editor.setCursorBufferPosition([3, Infinity]) - editor.insertText('\n ') - expect(editor.indentationForBufferRow(4)).toBe editor.indentationForBufferRow(3) - editor.insertText('}') - expect(editor.indentationForBufferRow(4)).toBe editor.indentationForBufferRow(3) - 1 - - it "doesn't break when decreasing the indentation on a row that has no indentation", -> - editor.setCursorBufferPosition([12, Infinity]) - editor.insertText("\n}; # too many closing brackets!") - expect(editor.lineTextForBufferRow(13)).toBe "}; # too many closing brackets!" - - describe "when inserted text does not match a decrease indent pattern", -> - it "does not decrease the indentation", -> - editor.setCursorBufferPosition([12, 0]) - editor.insertText(' ') - expect(editor.lineTextForBufferRow(12)).toBe ' };' - editor.insertText('\t\t') - expect(editor.lineTextForBufferRow(12)).toBe ' \t\t};' - - describe "when the current line does not match a decrease indent pattern", -> - it "leaves the line unchanged", -> - editor.setCursorBufferPosition([2, 4]) - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - editor.insertText('foo') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - - describe "atomic soft tabs", -> - it "skips tab-length runs of leading whitespace when moving the cursor", -> - editor.update({tabLength: 4, atomicSoftTabs: true}) - - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 4] - - editor.update({atomicSoftTabs: false}) - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 3] - - editor.update({atomicSoftTabs: true}) - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 4] - - describe ".destroy()", -> - it "destroys marker layers associated with the text editor", -> - buffer.retain() - selectionsMarkerLayerId = editor.selectionsMarkerLayer.id - foldsMarkerLayerId = editor.displayLayer.foldsMarkerLayer.id - editor.destroy() - expect(buffer.getMarkerLayer(selectionsMarkerLayerId)).toBeUndefined() - expect(buffer.getMarkerLayer(foldsMarkerLayerId)).toBeUndefined() - buffer.release() - - it "notifies ::onDidDestroy observers when the editor is destroyed", -> - destroyObserverCalled = false - editor.onDidDestroy -> destroyObserverCalled = true - - editor.destroy() - expect(destroyObserverCalled).toBe true - - it "does not blow up when query methods are called afterward", -> - editor.destroy() - editor.getGrammar() - editor.getLastCursor() - editor.lineTextForBufferRow(0) - - it "emits the destroy event after destroying the editor's buffer", -> - events = [] - editor.getBuffer().onDidDestroy -> - expect(editor.isDestroyed()).toBe(true) - events.push('buffer-destroyed') - editor.onDidDestroy -> - expect(buffer.isDestroyed()).toBe(true) - events.push('editor-destroyed') - editor.destroy() - expect(events).toEqual(['buffer-destroyed', 'editor-destroyed']) - - describe ".joinLines()", -> - describe "when no text is selected", -> - describe "when the line below isn't empty", -> - it "joins the line below with the current line separated by a space and moves the cursor to the start of line that was moved up", -> - editor.setCursorBufferPosition([0, Infinity]) - editor.insertText(' ') - editor.setCursorBufferPosition([0]) - editor.joinLines() - expect(editor.lineTextForBufferRow(0)).toBe 'var quicksort = function () { var sort = function(items) {' - expect(editor.getCursorBufferPosition()).toEqual [0, 29] - - describe "when the line below is empty", -> - it "deletes the line below and moves the cursor to the end of the line", -> - editor.setCursorBufferPosition([9]) - editor.joinLines() - expect(editor.lineTextForBufferRow(9)).toBe ' };' - expect(editor.lineTextForBufferRow(10)).toBe ' return sort(Array.apply(this, arguments));' - expect(editor.getCursorBufferPosition()).toEqual [9, 4] - - describe "when the cursor is on the last row", -> - it "does nothing", -> - editor.setCursorBufferPosition([Infinity, Infinity]) - editor.joinLines() - expect(editor.lineTextForBufferRow(12)).toBe '};' - - describe "when the line is empty", -> - it "joins the line below with the current line with no added space", -> - editor.setCursorBufferPosition([10]) - editor.joinLines() - expect(editor.lineTextForBufferRow(10)).toBe 'return sort(Array.apply(this, arguments));' - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - describe "when text is selected", -> - describe "when the selection does not span multiple lines", -> - it "joins the line below with the current line separated by a space and retains the selected text", -> - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - editor.joinLines() - expect(editor.lineTextForBufferRow(0)).toBe 'var quicksort = function () { var sort = function(items) {' - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [0, 3]] - - describe "when the selection spans multiple lines", -> - it "joins all selected lines separated by a space and retains the selected text", -> - editor.setSelectedBufferRange([[9, 3], [12, 1]]) - editor.joinLines() - expect(editor.lineTextForBufferRow(9)).toBe ' }; return sort(Array.apply(this, arguments)); };' - expect(editor.getSelectedBufferRange()).toEqual [[9, 3], [9, 49]] - - describe ".duplicateLines()", -> - it "for each selection, duplicates all buffer lines intersected by the selection", -> - editor.foldBufferRow(4) - editor.setCursorBufferPosition([2, 5]) - editor.addSelectionForBufferRange([[3, 0], [8, 0]], preserveFolds: true) - - editor.duplicateLines() - - expect(editor.getTextInBufferRange([[2, 0], [13, 5]])).toBe """ - \ if (items.length <= 1) return items; - if (items.length <= 1) return items; - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - """ - expect(editor.getSelectedBufferRanges()).toEqual [[[3, 5], [3, 5]], [[9, 0], [14, 0]]] - - # folds are also duplicated - expect(editor.isFoldedAtScreenRow(5)).toBe(true) - expect(editor.isFoldedAtScreenRow(7)).toBe(true) - expect(editor.lineTextForScreenRow(7)).toBe " while(items.length > 0) {" + editor.displayLayer.foldCharacter - expect(editor.lineTextForScreenRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - it "duplicates all folded lines for empty selections on lines containing folds", -> - editor.foldBufferRow(4) - editor.setCursorBufferPosition([4, 0]) - - editor.duplicateLines() - - expect(editor.getTextInBufferRange([[2, 0], [11, 5]])).toBe """ - \ if (items.length <= 1) return items; - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - """ - expect(editor.getSelectedBufferRange()).toEqual [[8, 0], [8, 0]] - - it "can duplicate the last line of the buffer", -> - editor.setSelectedBufferRange([[11, 0], [12, 2]]) - editor.duplicateLines() - expect(editor.getTextInBufferRange([[11, 0], [14, 2]])).toBe """ - \ return sort(Array.apply(this, arguments)); - }; - return sort(Array.apply(this, arguments)); - }; - """ - expect(editor.getSelectedBufferRange()).toEqual [[13, 0], [14, 2]] - - it "only duplicates lines containing multiple selections once", -> - editor.setText(""" - aaaaaa - bbbbbb - cccccc - dddddd - """) - editor.setSelectedBufferRanges([ - [[0, 1], [0, 2]], - [[0, 3], [0, 4]], - [[2, 1], [2, 2]], - [[2, 3], [3, 1]], - [[3, 3], [3, 4]], - ]) - editor.duplicateLines() - expect(editor.getText()).toBe(""" - aaaaaa - aaaaaa - bbbbbb - cccccc - dddddd - cccccc - dddddd - """) - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 1], [1, 2]], - [[1, 3], [1, 4]], - [[5, 1], [5, 2]], - [[5, 3], [6, 1]], - [[6, 3], [6, 4]], - ]) - - describe "when the editor contains surrogate pair characters", -> - it "correctly backspaces over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') - editor.moveToBottom() - editor.backspace() - expect(editor.getText()).toBe '\uD835\uDF97\uD835\uDF97' - editor.backspace() - expect(editor.getText()).toBe '\uD835\uDF97' - editor.backspace() - expect(editor.getText()).toBe '' - - it "correctly deletes over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') - editor.moveToTop() - editor.delete() - expect(editor.getText()).toBe '\uD835\uDF97\uD835\uDF97' - editor.delete() - expect(editor.getText()).toBe '\uD835\uDF97' - editor.delete() - expect(editor.getText()).toBe '' - - it "correctly moves over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97\n') - editor.moveToTop() - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe "when the editor contains variation sequence character pairs", -> - it "correctly backspaces over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') - editor.moveToBottom() - editor.backspace() - expect(editor.getText()).toBe '\u2714\uFE0E\u2714\uFE0E' - editor.backspace() - expect(editor.getText()).toBe '\u2714\uFE0E' - editor.backspace() - expect(editor.getText()).toBe '' - - it "correctly deletes over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') - editor.moveToTop() - editor.delete() - expect(editor.getText()).toBe '\u2714\uFE0E\u2714\uFE0E' - editor.delete() - expect(editor.getText()).toBe '\u2714\uFE0E' - editor.delete() - expect(editor.getText()).toBe '' - - it "correctly moves over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E\n') - editor.moveToTop() - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".setIndentationForBufferRow", -> - describe "when the editor uses soft tabs but the row has hard tabs", -> - it "only replaces whitespace characters", -> - editor.setSoftWrapped(true) - editor.setText("\t1\n\t2") - editor.setCursorBufferPosition([0, 0]) - editor.setIndentationForBufferRow(0, 2) - expect(editor.getText()).toBe(" 1\n\t2") - - describe "when the indentation level is a non-integer", -> - it "does not throw an exception", -> - editor.setSoftWrapped(true) - editor.setText("\t1\n\t2") - editor.setCursorBufferPosition([0, 0]) - editor.setIndentationForBufferRow(0, 2.1) - expect(editor.getText()).toBe(" 1\n\t2") - - describe "when the editor's grammar has an injection selector", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-text') - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - it "includes the grammar's patterns when the selector matches the current scope in other grammars", -> - waitsForPromise -> - atom.packages.activatePackage('language-hyperlink') - - runs -> - grammar = atom.grammars.selectGrammar("text.js") - {line, tags} = grammar.tokenizeLine("var i; // http://github.com") - - tokens = atom.grammars.decodeTokens(line, tags) - expect(tokens[0].value).toBe "var" - expect(tokens[0].scopes).toEqual ["source.js", "storage.type.var.js"] - - expect(tokens[6].value).toBe "http://github.com" - expect(tokens[6].scopes).toEqual ["source.js", "comment.line.double-slash.js", "markup.underline.link.http.hyperlink"] - - describe "when the grammar is added", -> - it "retokenizes existing buffers that contain tokens that match the injection selector", -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - editor.setText("// http://github.com") - - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('language-hyperlink') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - {text: 'http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--markup syntax--underline syntax--link syntax--http syntax--hyperlink']} - ] - - describe "when the grammar is updated", -> - it "retokenizes existing buffers that contain tokens that match the injection selector", -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - editor.setText("// SELECT * FROM OCTOCATS") - - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('package-with-injection-selector') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('language-sql') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: 'SELECT', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: '*', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--operator syntax--star syntax--sql']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: 'FROM', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, - {text: ' OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - describe ".normalizeTabsInBufferRange()", -> - it "normalizes tabs depending on the editor's soft tab/tab length settings", -> - editor.setTabLength(1) - editor.setSoftTabs(true) - editor.setText('\t\t\t') - editor.normalizeTabsInBufferRange([[0, 0], [0, 1]]) - expect(editor.getText()).toBe ' \t\t' - - editor.setTabLength(2) - editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) - expect(editor.getText()).toBe ' ' - - editor.setSoftTabs(false) - editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) - expect(editor.getText()).toBe ' ' - - describe ".pageUp/Down()", -> - it "moves the cursor down one page length", -> - editor.update(autoHeight: false) - element = editor.getElement() - jasmine.attachToDOM(element) - element.style.height = element.component.getLineHeight() * 5 + 'px' - element.measureDimensions() - - expect(editor.getCursorBufferPosition().row).toBe 0 - - editor.pageDown() - expect(editor.getCursorBufferPosition().row).toBe 5 - - editor.pageDown() - expect(editor.getCursorBufferPosition().row).toBe 10 - - editor.pageUp() - expect(editor.getCursorBufferPosition().row).toBe 5 - - editor.pageUp() - expect(editor.getCursorBufferPosition().row).toBe 0 - - describe ".selectPageUp/Down()", -> - it "selects one screen height of text up or down", -> - editor.update(autoHeight: false) - element = editor.getElement() - jasmine.attachToDOM(element) - element.style.height = element.component.getLineHeight() * 5 + 'px' - element.measureDimensions() - - expect(editor.getCursorBufferPosition().row).toBe 0 - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [5, 0]]] - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [10, 0]]] - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] - - editor.moveToBottom() - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[7, 0], [12, 2]]] - - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 0], [12, 2]]] - - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] - - describe "::scrollToScreenPosition(position, [options])", -> - it "triggers ::onDidRequestAutoscroll with the logical coordinates along with the options", -> - scrollSpy = jasmine.createSpy("::onDidRequestAutoscroll") - editor.onDidRequestAutoscroll(scrollSpy) - - editor.scrollToScreenPosition([8, 20]) - editor.scrollToScreenPosition([8, 20], center: true) - editor.scrollToScreenPosition([8, 20], center: false, reversed: true) - - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {}) - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {center: true}) - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {center: false, reversed: true}) - - describe "scroll past end", -> - it "returns false by default but can be customized", -> - expect(editor.getScrollPastEnd()).toBe(false) - editor.update({scrollPastEnd: true}) - expect(editor.getScrollPastEnd()).toBe(true) - editor.update({scrollPastEnd: false}) - expect(editor.getScrollPastEnd()).toBe(false) - - it "always returns false when autoHeight is on", -> - editor.update({autoHeight: true, scrollPastEnd: true}) - expect(editor.getScrollPastEnd()).toBe(false) - editor.update({autoHeight: false}) - expect(editor.getScrollPastEnd()).toBe(true) - - describe "auto height", -> - it "returns true by default but can be customized", -> - editor = new TextEditor - expect(editor.getAutoHeight()).toBe(true) - editor.update({autoHeight: false}) - expect(editor.getAutoHeight()).toBe(false) - editor.update({autoHeight: true}) - expect(editor.getAutoHeight()).toBe(true) - editor.destroy() - - describe "auto width", -> - it "returns false by default but can be customized", -> - expect(editor.getAutoWidth()).toBe(false) - editor.update({autoWidth: true}) - expect(editor.getAutoWidth()).toBe(true) - editor.update({autoWidth: false}) - expect(editor.getAutoWidth()).toBe(false) - - describe '.get/setPlaceholderText()', -> - it 'can be created with placeholderText', -> - newEditor = new TextEditor({ - mini: true - placeholderText: 'yep' - }) - expect(newEditor.getPlaceholderText()).toBe 'yep' - - it 'models placeholderText and emits an event when changed', -> - editor.onDidChangePlaceholderText handler = jasmine.createSpy() - - expect(editor.getPlaceholderText()).toBeUndefined() - - editor.setPlaceholderText('OK') - expect(handler).toHaveBeenCalledWith 'OK' - expect(editor.getPlaceholderText()).toBe 'OK' - - describe 'gutters', -> - describe 'the TextEditor constructor', -> - it 'creates a line-number gutter', -> - expect(editor.getGutters().length).toBe 1 - lineNumberGutter = editor.gutterWithName('line-number') - expect(lineNumberGutter.name).toBe 'line-number' - expect(lineNumberGutter.priority).toBe 0 - - describe '::addGutter', -> - it 'can add a gutter', -> - expect(editor.getGutters().length).toBe 1 # line-number gutter - options = - name: 'test-gutter' - priority: 1 - gutter = editor.addGutter options - expect(editor.getGutters().length).toBe 2 - expect(editor.getGutters()[1]).toBe gutter - - it "does not allow a custom gutter with the 'line-number' name.", -> - expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow() - - describe '::decorateMarker', -> - [marker] = [] - - beforeEach -> - marker = editor.markBufferRange([[1, 0], [1, 0]]) - - it 'reflects an added decoration when one of its custom gutters is decorated.', -> - gutter = editor.addGutter {'name': 'custom-gutter'} - decoration = gutter.decorateMarker marker, {class: 'custom-class'} - gutterDecorations = editor.getDecorations - type: 'gutter' - gutterName: 'custom-gutter' - class: 'custom-class' - expect(gutterDecorations.length).toBe 1 - expect(gutterDecorations[0]).toBe decoration - - it 'reflects an added decoration when its line-number gutter is decorated.', -> - decoration = editor.gutterWithName('line-number').decorateMarker marker, {class: 'test-class'} - gutterDecorations = editor.getDecorations - type: 'line-number' - gutterName: 'line-number' - class: 'test-class' - expect(gutterDecorations.length).toBe 1 - expect(gutterDecorations[0]).toBe decoration - - describe '::observeGutters', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback immediately with each existing gutter, and with each added gutter after that.', -> - lineNumberGutter = editor.gutterWithName('line-number') - editor.observeGutters(callback) - expect(payloads).toEqual [lineNumberGutter] - gutter1 = editor.addGutter({name: 'test-gutter-1'}) - expect(payloads).toEqual [lineNumberGutter, gutter1] - gutter2 = editor.addGutter({name: 'test-gutter-2'}) - expect(payloads).toEqual [lineNumberGutter, gutter1, gutter2] - - it 'does not call the callback when a gutter is removed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - editor.observeGutters(callback) - payloads = [] - gutter.destroy() - expect(payloads).toEqual [] - - it 'does not call the callback after the subscription has been disposed.', -> - subscription = editor.observeGutters(callback) - payloads = [] - subscription.dispose() - editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [] - - describe '::onDidAddGutter', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback with each newly-added gutter, but not with existing gutters.', -> - editor.onDidAddGutter(callback) - expect(payloads).toEqual [] - gutter = editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [gutter] - - it 'does not call the callback after the subscription has been disposed.', -> - subscription = editor.onDidAddGutter(callback) - payloads = [] - subscription.dispose() - editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [] - - describe '::onDidRemoveGutter', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback when a gutter is removed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - editor.onDidRemoveGutter(callback) - expect(payloads).toEqual [] - gutter.destroy() - expect(payloads).toEqual ['test-gutter'] - - it 'does not call the callback after the subscription has been disposed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - subscription = editor.onDidRemoveGutter(callback) - subscription.dispose() - gutter.destroy() - expect(payloads).toEqual [] - - describe "decorations", -> - describe "::decorateMarker", -> - it "includes the decoration in the object returned from ::decorationsStateForScreenRowRange", -> - marker = editor.markBufferRange([[2, 4], [6, 8]]) - decoration = editor.decorateMarker(marker, type: 'highlight', class: 'foo') - expect(editor.decorationsStateForScreenRowRange(0, 5)[decoration.id]).toEqual { - properties: {type: 'highlight', class: 'foo'} - screenRange: marker.getScreenRange(), - bufferRange: marker.getBufferRange(), - rangeIsReversed: false - } - - it "does not throw errors after the marker's containing layer is destroyed", -> - layer = editor.addMarkerLayer() - marker = layer.markBufferRange([[2, 4], [6, 8]]) - decoration = editor.decorateMarker(marker, type: 'highlight', class: 'foo') - layer.destroy() - editor.decorationsStateForScreenRowRange(0, 5) - - describe "::decorateMarkerLayer", -> - it "based on the markers in the layer, includes multiple decoration objects with the same properties and different ranges in the object returned from ::decorationsStateForScreenRowRange", -> - layer1 = editor.getBuffer().addMarkerLayer() - marker1 = layer1.markRange([[2, 4], [6, 8]]) - marker2 = layer1.markRange([[11, 0], [11, 12]]) - layer2 = editor.getBuffer().addMarkerLayer() - marker3 = layer2.markRange([[8, 0], [9, 0]]) - - layer1Decoration1 = editor.decorateMarkerLayer(layer1, type: 'highlight', class: 'foo') - layer1Decoration2 = editor.decorateMarkerLayer(layer1, type: 'highlight', class: 'bar') - layer2Decoration = editor.decorateMarkerLayer(layer2, type: 'highlight', class: 'baz') - - decorationState = editor.decorationsStateForScreenRowRange(0, 13) - - expect(decorationState["#{layer1Decoration1.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'foo'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration1.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'foo'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer2Decoration.id}-#{marker3.id}"]).toEqual { - properties: {type: 'highlight', class: 'baz'}, - screenRange: marker3.getRange(), - bufferRange: marker3.getRange(), - rangeIsReversed: false - } - - layer1Decoration1.destroy() - - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration1.id}-#{marker1.id}"]).toBeUndefined() - expect(decorationState["#{layer1Decoration1.id}-#{marker2.id}"]).toBeUndefined() - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer2Decoration.id}-#{marker3.id}"]).toEqual { - properties: {type: 'highlight', class: 'baz'}, - screenRange: marker3.getRange(), - bufferRange: marker3.getRange(), - rangeIsReversed: false - } - - layer1Decoration2.setPropertiesForMarker(marker1, {type: 'highlight', class: 'quux'}) - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'quux'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - - layer1Decoration2.setPropertiesForMarker(marker1, null) - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - - describe "invisibles", -> - beforeEach -> - editor.update({showInvisibles: true}) - - it "substitutes invisible characters according to the given rules", -> - previousLineText = editor.lineTextForScreenRow(0) - editor.update({invisibles: {eol: '?'}}) - expect(editor.lineTextForScreenRow(0)).not.toBe(previousLineText) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) - expect(editor.getInvisibles()).toEqual(eol: '?') - - it "does not use invisibles if showInvisibles is set to false", -> - editor.update({invisibles: {eol: '?'}}) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) - - editor.update({showInvisibles: false}) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(false) - - describe "indent guides", -> - it "shows indent guides when `editor.showIndentGuide` is set to true and the editor is not mini", -> - editor.setText(" foo") - editor.setTabLength(2) - - editor.update({showIndentGuide: false}) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - editor.update({showIndentGuide: true}) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace indent-guide']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - editor.setMini(true) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - describe "when the editor is constructed with the grammar option set", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - it "sets the grammar", -> - editor = new TextEditor({grammar: atom.grammars.grammarForScopeName('source.coffee')}) - expect(editor.getGrammar().name).toBe 'CoffeeScript' - - describe "softWrapAtPreferredLineLength", -> - it "soft wraps the editor at the preferred line length unless the editor is narrower or the editor is mini", -> - editor.update({ - editorWidthInChars: 30 - softWrapped: true - softWrapAtPreferredLineLength: true - preferredLineLength: 20 - }) - - expect(editor.lineTextForScreenRow(0)).toBe 'var quicksort = ' - - editor.update({editorWidthInChars: 10}) - expect(editor.lineTextForScreenRow(0)).toBe 'var ' - - editor.update({mini: true}) - expect(editor.lineTextForScreenRow(0)).toBe 'var quicksort = function () {' - - describe "softWrapHangingIndentLength", -> - it "controls how much extra indentation is applied to soft-wrapped lines", -> - editor.setText('123456789') - editor.update({ - editorWidthInChars: 8 - softWrapped: true - softWrapHangingIndentLength: 2 - }) - expect(editor.lineTextForScreenRow(1)).toEqual ' 9' - - editor.update({softWrapHangingIndentLength: 4}) - expect(editor.lineTextForScreenRow(1)).toEqual ' 9' - - describe "::getElement", -> - it "returns an element", -> - expect(editor.getElement() instanceof HTMLElement).toBe(true) - - describe 'setMaxScreenLineLength', -> - it "sets the maximum line length in the editor before soft wrapping is forced", -> - expect(editor.getSoftWrapColumn()).toBe(500) - editor.update({ - maxScreenLineLength: 1500 - }) - expect(editor.getSoftWrapColumn()).toBe(1500) diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index c81df8089..382d020d4 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -1,7 +1,6668 @@ +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') + const fs = require('fs') +const path = require('path') const temp = require('temp').track() -const {Point, Range} = require('text-buffer') -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const dedent = require('dedent') +const clipboard = require('../src/safe-clipboard') +const TextEditor = require('../src/text-editor') +const TextBuffer = require('text-buffer') + +describe('TextEditor', () => { + let buffer, editor, lineLengths + + beforeEach(async () => { + editor = await atom.workspace.open('sample.js') + buffer = editor.buffer + editor.update({autoIndent: false}) + lineLengths = buffer.getLines().map(line => line.length) + await atom.packages.activatePackage('language-javascript') + }) + + describe('when the editor is deserialized', () => { + it('restores selections and folds based on markers in the buffer', async () => { + editor.setSelectedBufferRange([[1, 2], [3, 4]]) + editor.addSelectionForBufferRange([[5, 6], [7, 5]], {reversed: true}) + editor.foldBufferRow(4) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + + const buffer2 = await TextBuffer.deserialize(editor.buffer.serialize()) + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return buffer2 }} + }) + + expect(editor2.id).toBe(editor.id) + expect(editor2.getBuffer().getPath()).toBe(editor.getBuffer().getPath()) + expect(editor2.getSelectedBufferRanges()).toEqual([[[1, 2], [3, 4]], [[5, 6], [7, 5]]]) + expect(editor2.getSelections()[1].isReversed()).toBeTruthy() + expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() + editor2.destroy() + }) + + it("restores the editor's layout configuration", async () => { + editor.update({ + softTabs: true, + atomicSoftTabs: false, + tabLength: 12, + softWrapped: true, + softWrapAtPreferredLineLength: true, + softWrapHangingIndentLength: 8, + invisibles: {space: 'S'}, + showInvisibles: true, + editorWidthInChars: 120 + }) + + // Force buffer and display layer to be deserialized as well, rather than + // reusing the same buffer instance + const buffer2 = await TextBuffer.deserialize(editor.buffer.serialize()) + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return buffer2 }} + }) + + expect(editor2.getSoftTabs()).toBe(editor.getSoftTabs()) + expect(editor2.hasAtomicSoftTabs()).toBe(editor.hasAtomicSoftTabs()) + expect(editor2.getTabLength()).toBe(editor.getTabLength()) + expect(editor2.getSoftWrapColumn()).toBe(editor.getSoftWrapColumn()) + expect(editor2.getSoftWrapHangingIndentLength()).toBe(editor.getSoftWrapHangingIndentLength()) + expect(editor2.getInvisibles()).toEqual(editor.getInvisibles()) + expect(editor2.getEditorWidthInChars()).toBe(editor.getEditorWidthInChars()) + expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength()) + expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn()) + }) + + it('ignores buffers with retired IDs', () => { + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return null }} + }) + + expect(editor2).toBeNull() + }) + }) + + describe('when the editor is constructed with the largeFileMode option set to true', () => { + it("loads the editor but doesn't tokenize", async () => { + editor = await atom.workspace.openTextFile('sample.js', {largeFileMode: true}) + buffer = editor.getBuffer() + expect(editor.lineTextForScreenRow(0)).toBe(buffer.lineForRow(0)) + expect(editor.tokensForScreenRow(0).length).toBe(1) + expect(editor.tokensForScreenRow(1).length).toBe(2) // soft tab + expect(editor.lineTextForScreenRow(12)).toBe(buffer.lineForRow(12)) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + editor.insertText('hey"') + expect(editor.tokensForScreenRow(0).length).toBe(1) + expect(editor.tokensForScreenRow(1).length).toBe(2) + }) + }) + + describe('.copy()', () => { + it('returns a different editor with the same initial state', () => { + expect(editor.getAutoHeight()).toBeFalsy() + expect(editor.getAutoWidth()).toBeFalsy() + expect(editor.getShowCursorOnSelection()).toBeTruthy() + + const element = editor.getElement() + element.setHeight(100) + element.setWidth(100) + jasmine.attachToDOM(element) + + editor.update({showCursorOnSelection: false}) + editor.setSelectedBufferRange([[1, 2], [3, 4]]) + editor.addSelectionForBufferRange([[5, 6], [7, 8]], {reversed: true}) + editor.setScrollTopRow(3) + expect(editor.getScrollTopRow()).toBe(3) + editor.setScrollLeftColumn(4) + expect(editor.getScrollLeftColumn()).toBe(4) + editor.foldBufferRow(4) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + + const editor2 = editor.copy() + const element2 = editor2.getElement() + element2.setHeight(100) + element2.setWidth(100) + jasmine.attachToDOM(element2) + expect(editor2.id).not.toBe(editor.id) + expect(editor2.getSelectedBufferRanges()).toEqual(editor.getSelectedBufferRanges()) + expect(editor2.getSelections()[1].isReversed()).toBeTruthy() + expect(editor2.getScrollTopRow()).toBe(3) + expect(editor2.getScrollLeftColumn()).toBe(4) + expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor2.getAutoWidth()).toBe(false) + expect(editor2.getAutoHeight()).toBe(false) + expect(editor2.getShowCursorOnSelection()).toBeFalsy() + + // editor2 can now diverge from its origin edit session + editor2.getLastSelection().setBufferRange([[2, 1], [4, 3]]) + expect(editor2.getSelectedBufferRanges()).not.toEqual(editor.getSelectedBufferRanges()) + editor2.unfoldBufferRow(4) + expect(editor2.isFoldedAtBufferRow(4)).not.toBe(editor.isFoldedAtBufferRow(4)) + }) + }) + + describe('.update()', () => { + it('updates the editor with the supplied config parameters', () => { + let changeSpy + const { element } = editor // force element initialization + element.setUpdatedSynchronously(false) + editor.update({showInvisibles: true}) + editor.onDidChange(changeSpy = jasmine.createSpy('onDidChange')) + + const returnedPromise = editor.update({ + tabLength: 6, + softTabs: false, + softWrapped: true, + editorWidthInChars: 40, + showInvisibles: false, + mini: false, + lineNumberGutterVisible: false, + scrollPastEnd: true, + autoHeight: false, + maxScreenLineLength: 1000 + }) + + expect(returnedPromise).toBe(element.component.getNextUpdatePromise()) + expect(changeSpy.callCount).toBe(1) + expect(editor.getTabLength()).toBe(6) + expect(editor.getSoftTabs()).toBe(false) + expect(editor.isSoftWrapped()).toBe(true) + expect(editor.getEditorWidthInChars()).toBe(40) + expect(editor.getInvisibles()).toEqual({}) + expect(editor.isMini()).toBe(false) + expect(editor.isLineNumberGutterVisible()).toBe(false) + expect(editor.getScrollPastEnd()).toBe(true) + expect(editor.getAutoHeight()).toBe(false) + }) + }) + + describe('title', () => { + describe('.getTitle()', () => { + it("uses the basename of the buffer's path as its title, or 'untitled' if the path is undefined", () => { + expect(editor.getTitle()).toBe('sample.js') + buffer.setPath(undefined) + expect(editor.getTitle()).toBe('untitled') + }) + }) + + describe('.getLongTitle()', () => { + it('returns file name when there is no opened file with identical name', () => { + expect(editor.getLongTitle()).toBe('sample.js') + buffer.setPath(undefined) + expect(editor.getLongTitle()).toBe('untitled') + }) + + it("returns ' — ' when opened files have identical file names", async () => { + const editor1 = await atom.workspace.open(path.join('sample-theme-1', 'readme')) + const editor2 = await atom.workspace.open(path.join('sample-theme-2', 'readme')) + expect(editor1.getLongTitle()).toBe('readme \u2014 sample-theme-1') + expect(editor2.getLongTitle()).toBe('readme \u2014 sample-theme-2') + }) + + it("returns ' — ' when opened files have identical file names in subdirectories", async () => { + const path1 = path.join('sample-theme-1', 'src', 'js') + const path2 = path.join('sample-theme-2', 'src', 'js') + const editor1 = await atom.workspace.open(path.join(path1, 'main.js')) + const editor2 = await atom.workspace.open(path.join(path2, 'main.js')) + expect(editor1.getLongTitle()).toBe(`main.js \u2014 ${path1}`) + expect(editor2.getLongTitle()).toBe(`main.js \u2014 ${path2}`) + }) + + it("returns ' — ' when opened files have identical file and same parent dir name", async () => { + const editor1 = await atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'main.js')) + const editor2 = await atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'plugin', 'main.js')) + expect(editor1.getLongTitle()).toBe('main.js \u2014 js') + expect(editor2.getLongTitle()).toBe(`main.js \u2014 ${path.join('js', 'plugin')}`) + }) + + it('returns the filename when the editor is not in the workspace', async () => { + editor.onDidDestroy(() => { + expect(editor.getLongTitle()).toBe('sample.js') + }) + + await atom.workspace.getActivePane().close() + expect(editor.isDestroyed()).toBe(true) + }) + }) + + it('notifies ::onDidChangeTitle observers when the underlying buffer path changes', () => { + const observed = [] + editor.onDidChangeTitle(title => observed.push(title)) + + buffer.setPath('/foo/bar/baz.txt') + buffer.setPath(undefined) + + expect(observed).toEqual(['baz.txt', 'untitled']) + }) + }) + + describe('path', () => { + it('notifies ::onDidChangePath observers when the underlying buffer path changes', () => { + const observed = [] + editor.onDidChangePath(filePath => observed.push(filePath)) + + buffer.setPath(__filename) + buffer.setPath(undefined) + + expect(observed).toEqual([__filename, undefined]) + }) + }) + + describe('encoding', () => { + it('notifies ::onDidChangeEncoding observers when the editor encoding changes', () => { + const observed = [] + editor.onDidChangeEncoding(encoding => observed.push(encoding)) + + editor.setEncoding('utf16le') + editor.setEncoding('utf16le') + editor.setEncoding('utf16be') + editor.setEncoding() + editor.setEncoding() + + expect(observed).toEqual(['utf16le', 'utf16be', 'utf8']) + }) + }) + + describe('cursor', () => { + describe('.getLastCursor()', () => { + it('returns the most recently created cursor', () => { + editor.addCursorAtScreenPosition([1, 0]) + const lastCursor = editor.addCursorAtScreenPosition([2, 0]) + expect(editor.getLastCursor()).toBe(lastCursor) + }) + + it('creates a new cursor at (0, 0) if the last cursor has been destroyed', () => { + editor.getLastCursor().destroy() + expect(editor.getLastCursor().getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.getCursors()', () => { + it('creates a new cursor at (0, 0) if the last cursor has been destroyed', () => { + editor.getLastCursor().destroy() + expect(editor.getCursors()[0].getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('when the cursor moves', () => { + it('clears a goal column established by vertical movement', () => { + editor.setText('b') + editor.setCursorBufferPosition([0, 0]) + editor.insertNewline() + editor.moveUp() + editor.insertText('a') + editor.moveDown() + expect(editor.getCursorBufferPosition()).toEqual([1, 1]) + }) + + it('emits an event with the old position, new position, and the cursor that moved', () => { + const cursorCallback = jasmine.createSpy('cursor-changed-position') + const editorCallback = jasmine.createSpy('editor-changed-cursor-position') + + editor.getLastCursor().onDidChangePosition(cursorCallback) + editor.onDidChangeCursorPosition(editorCallback) + + editor.setCursorBufferPosition([2, 4]) + + expect(editorCallback).toHaveBeenCalled() + expect(cursorCallback).toHaveBeenCalled() + const eventObject = editorCallback.mostRecentCall.args[0] + expect(cursorCallback.mostRecentCall.args[0]).toEqual(eventObject) + + expect(eventObject.oldBufferPosition).toEqual([0, 0]) + expect(eventObject.oldScreenPosition).toEqual([0, 0]) + expect(eventObject.newBufferPosition).toEqual([2, 4]) + expect(eventObject.newScreenPosition).toEqual([2, 4]) + expect(eventObject.cursor).toBe(editor.getLastCursor()) + }) + }) + + describe('.setCursorScreenPosition(screenPosition)', () => { + it('clears a goal column established by vertical movement', () => { + // set a goal column by moving down + editor.setCursorScreenPosition({row: 3, column: lineLengths[3]}) + editor.moveDown() + expect(editor.getCursorScreenPosition().column).not.toBe(6) + + // clear the goal column by explicitly setting the cursor position + editor.setCursorScreenPosition([4, 6]) + expect(editor.getCursorScreenPosition().column).toBe(6) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(6) + }) + + it('merges multiple cursors', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 1]) + const [cursor1, cursor2] = editor.getCursors() + editor.setCursorScreenPosition([4, 7]) + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursors()).toEqual([cursor1]) + expect(editor.getCursorScreenPosition()).toEqual([4, 7]) + }) + + describe('when soft-wrap is enabled and code is folded', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + editor.foldBufferRowRange(2, 3) + }) + + it('positions the cursor at the buffer position that corresponds to the given screen position', () => { + editor.setCursorScreenPosition([9, 0]) + expect(editor.getCursorBufferPosition()).toEqual([8, 11]) + }) + }) + }) + + describe('.moveUp()', () => { + it('moves the cursor up', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([1, 2]) + }) + + it('retains the goal column across lines of differing length', () => { + expect(lineLengths[6]).toBeGreaterThan(32) + editor.setCursorScreenPosition({row: 6, column: 32}) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[5]) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[4]) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(32) + }) + + describe('when the cursor is on the first line', () => { + it('moves the cursor to the beginning of the line, but retains the goal column', () => { + editor.setCursorScreenPosition([0, 4]) + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([1, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[4, 9], [5, 10]])) + + it('moves above the selection', () => { + const cursor = editor.getLastCursor() + editor.moveUp() + expect(cursor.getBufferPosition()).toEqual([3, 9]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.addCursorAtScreenPosition([1, 0]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveUp() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + }) + + describe('when the cursor was moved down from the beginning of an indented soft-wrapped line', () => { + it('moves to the beginning of the previous line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + + editor.setCursorScreenPosition([3, 0]) + editor.moveDown() + editor.moveDown() + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([4, 4]) + }) + }) + }) + + describe('.moveDown()', () => { + it('moves the cursor down', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([3, 2]) + }) + + it('retains the goal column across lines of differing length', () => { + editor.setCursorScreenPosition({row: 3, column: lineLengths[3]}) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[4]) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[5]) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[3]) + }) + + describe('when the cursor is on the last line', () => { + it('moves the cursor to the end of line, but retains the goal column when moving back up', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + editor.setCursorScreenPosition({row: lastLineIndex, column: editor.getTabLength()}) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual({row: lastLineIndex, column: lastLine.length}) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(editor.getTabLength()) + }) + + it('retains a goal column of 0 when moving back up', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + editor.setCursorScreenPosition({row: lastLineIndex, column: 0}) + editor.moveDown() + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(0) + }) + }) + + describe('when the cursor is at the beginning of an indented soft-wrapped line', () => { + it("moves to the beginning of the line's continuation on the next screen row", () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + + editor.setCursorScreenPosition([3, 0]) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([4, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[4, 9], [5, 10]])) + + it('moves below the selection', () => { + const cursor = editor.getLastCursor() + editor.moveDown() + expect(cursor.getBufferPosition()).toEqual([6, 10]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([11, 2]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveDown() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveLeft()', () => { + it('moves the cursor by one column to the left', () => { + editor.setCursorScreenPosition([1, 8]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([1, 7]) + }) + + it('moves the cursor by n columns to the left', () => { + editor.setCursorScreenPosition([1, 8]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([1, 4]) + }) + + it('moves the cursor by two rows up when the columnCount is longer than an entire line', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveLeft(34) + expect(editor.getCursorScreenPosition()).toEqual([0, 29]) + }) + + it('moves the cursor to the beginning columnCount is longer than the position in the buffer', () => { + editor.setCursorScreenPosition([1, 0]) + editor.moveLeft(100) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + + describe('when the cursor is in the first column', () => { + describe('when there is a previous line', () => { + it('wraps to the end of the previous line', () => { + editor.setCursorScreenPosition({row: 1, column: 0}) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual({row: 0, column: buffer.lineForRow(0).length}) + }) + + it('moves the cursor by one row up and n columns to the left', () => { + editor.setCursorScreenPosition([1, 0]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([0, 26]) + }) + }) + + describe('when the next line is empty', () => { + it('wraps to the beginning of the previous line', () => { + editor.setCursorScreenPosition([11, 0]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([10, 0]) + }) + }) + + describe('when line is wrapped and follow previous line indentation', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + }) + + it('wraps to the end of the previous line', () => { + editor.setCursorScreenPosition([4, 4]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([3, 46]) + }) + }) + + describe('when the cursor is on the first line', () => { + it('remains in the same position (0,0)', () => { + editor.setCursorScreenPosition({row: 0, column: 0}) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual({row: 0, column: 0}) + }) + + it('remains in the same position (0,0) when columnCount is specified', () => { + editor.setCursorScreenPosition([0, 0]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + }) + }) + + describe('when softTabs is enabled and the cursor is preceded by leading whitespace', () => { + it('skips tabLength worth of whitespace at a time', () => { + editor.setCursorBufferPosition([5, 6]) + + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([5, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[5, 22], [5, 27]])) + + it('moves to the left of the selection', () => { + const cursor = editor.getLastCursor() + editor.moveLeft() + expect(cursor.getBufferPosition()).toEqual([5, 22]) + + editor.moveLeft() + expect(cursor.getBufferPosition()).toEqual([5, 21]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 1]) + + const [cursor1, cursor2] = editor.getCursors() + editor.moveLeft() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveRight()', () => { + it('moves the cursor by one column to the right', () => { + editor.setCursorScreenPosition([3, 3]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([3, 4]) + }) + + it('moves the cursor by n columns to the right', () => { + editor.setCursorScreenPosition([3, 7]) + editor.moveRight(4) + expect(editor.getCursorScreenPosition()).toEqual([3, 11]) + }) + + it('moves the cursor by two rows down when the columnCount is longer than an entire line', () => { + editor.setCursorScreenPosition([0, 29]) + editor.moveRight(34) + expect(editor.getCursorScreenPosition()).toEqual([2, 2]) + }) + + it('moves the cursor to the end of the buffer when columnCount is longer than the number of characters following the cursor position', () => { + editor.setCursorScreenPosition([11, 5]) + editor.moveRight(100) + expect(editor.getCursorScreenPosition()).toEqual([12, 2]) + }) + + describe('when the cursor is on the last column of a line', () => { + describe('when there is a subsequent line', () => { + it('wraps to the beginning of the next line', () => { + editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([1, 0]) + }) + + it('moves the cursor by one row down and n columns to the right', () => { + editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) + editor.moveRight(4) + expect(editor.getCursorScreenPosition()).toEqual([1, 3]) + }) + }) + + describe('when the next line is empty', () => { + it('wraps to the beginning of the next line', () => { + editor.setCursorScreenPosition([9, 4]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([10, 0]) + }) + }) + + describe('when the cursor is on the last line', () => { + it('remains in the same position', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + const lastPosition = {row: lastLineIndex, column: lastLine.length} + editor.setCursorScreenPosition(lastPosition) + editor.moveRight() + + expect(editor.getCursorScreenPosition()).toEqual(lastPosition) + }) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[5, 22], [5, 27]])) + + it('moves to the left of the selection', () => { + const cursor = editor.getLastCursor() + editor.moveRight() + expect(cursor.getBufferPosition()).toEqual([5, 27]) + + editor.moveRight() + expect(cursor.getBufferPosition()).toEqual([5, 28]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([12, 1]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveRight() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveToTop()', () => { + it('moves the cursor to the top of the buffer', () => { + editor.setCursorScreenPosition([11, 1]) + editor.addCursorAtScreenPosition([12, 0]) + editor.moveToTop() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToBottom()', () => { + it('moves the cursor to the bottom of the buffer', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + editor.moveToBottom() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveToBeginningOfScreenLine()', () => { + describe('when soft wrap is on', () => { + it('moves cursor to the beginning of the screen line', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToBeginningOfScreenLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([1, 0]) + }) + }) + + describe('when soft wrap is off', () => { + it('moves cursor to the beginning of the line', () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + editor.moveToBeginningOfScreenLine() + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + }) + }) + }) + + describe('.moveToEndOfScreenLine()', () => { + describe('when soft wrap is on', () => { + it('moves cursor to the beginning of the screen line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToEndOfScreenLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([1, 9]) + }) + }) + + describe('when soft wrap is off', () => { + it('moves cursor to the end of line', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + editor.moveToEndOfScreenLine() + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 29]) + expect(cursor2.getBufferPosition()).toEqual([1, 30]) + }) + }) + }) + + describe('.moveToBeginningOfLine()', () => { + it('moves cursor to the beginning of the buffer line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToBeginningOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToEndOfLine()', () => { + it('moves cursor to the end of the buffer line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([0, 2]) + editor.moveToEndOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([4, 4]) + }) + }) + + describe('.moveToFirstCharacterOfLine()', () => { + describe('when soft wrap is on', () => { + it("moves to the first character of the current screen line or the beginning of the screen line if it's already on the first character", () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([2, 5]) + editor.addCursorAtScreenPosition([8, 7]) + + editor.moveToFirstCharacterOfLine() + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getScreenPosition()).toEqual([2, 0]) + expect(cursor2.getScreenPosition()).toEqual([8, 2]) + + editor.moveToFirstCharacterOfLine() + expect(cursor1.getScreenPosition()).toEqual([2, 0]) + expect(cursor2.getScreenPosition()).toEqual([8, 2]) + }) + }) + + describe('when soft wrap is off', () => { + it("moves to the first character of the current line or the beginning of the line if it's already on the first character", () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + + editor.moveToFirstCharacterOfLine() + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 2]) + + editor.moveToFirstCharacterOfLine() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + }) + + it('moves to the beginning of the line if it only contains whitespace ', () => { + editor.setText('first\n \nthird') + editor.setCursorScreenPosition([1, 2]) + editor.moveToFirstCharacterOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getBufferPosition()).toEqual([1, 0]) + }) + + describe('when invisible characters are enabled with soft tabs', () => { + it('moves to the first character of the current line without being confused by the invisible characters', () => { + editor.update({showInvisibles: true}) + editor.setCursorScreenPosition([1, 7]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + }) + }) + + describe('when invisible characters are enabled with hard tabs', () => { + it('moves to the first character of the current line without being confused by the invisible characters', () => { + editor.update({showInvisibles: true}) + buffer.setTextInRange([[1, 0], [1, Infinity]], '\t\t\ta', {normalizeLineEndings: false}) + + editor.setCursorScreenPosition([1, 7]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 3]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + }) + }) + }) + }) + + describe('.moveToBeginningOfWord()', () => { + it('moves the cursor to the beginning of the word', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([1, 12]) + editor.addCursorAtBufferPosition([3, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToBeginningOfWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([1, 11]) + expect(cursor3.getBufferPosition()).toEqual([2, 39]) + }) + + it('does not fail at position [0, 0]', () => { + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfWord() + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([11, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('treats lines with only whitespace as a word (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([11, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([9, 2]) + }) + + it('works when the current line is blank (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([9, 2]) + editor.buffer.setText(buffer.getText().replace(/\r\n/g, '\n')) + }) + }) + + describe('.moveToPreviousWordBoundary()', () => { + it('moves the cursor to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 0]) + editor.addCursorAtBufferPosition([2, 4]) + editor.addCursorAtBufferPosition([3, 14]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.moveToPreviousWordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([1, 30]) + expect(cursor3.getBufferPosition()).toEqual([2, 0]) + expect(cursor4.getBufferPosition()).toEqual([3, 13]) + }) + }) + + describe('.moveToNextWordBoundary()', () => { + it('moves the cursor to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 40]) + editor.addCursorAtBufferPosition([3, 0]) + editor.addCursorAtBufferPosition([3, 30]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.moveToNextWordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 0]) + expect(cursor3.getBufferPosition()).toEqual([3, 4]) + expect(cursor4.getBufferPosition()).toEqual([3, 31]) + }) + }) + + describe('.moveToEndOfWord()', () => { + it('moves the cursor to the end of the word', () => { + editor.setCursorBufferPosition([0, 6]) + editor.addCursorAtBufferPosition([1, 10]) + editor.addCursorAtBufferPosition([2, 40]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToEndOfWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([1, 12]) + expect(cursor3.getBufferPosition()).toEqual([3, 7]) + }) + + it('does not blow up when there is no next word', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + const endPosition = editor.getCursorBufferPosition() + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual(endPosition) + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([9, 4]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('treats lines with only whitespace as a word (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([9, 4]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 8]) + }) + + it('works when the current line is blank (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([10, 0]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 8]) + }) + }) + + describe('.moveToBeginningOfNextWord()', () => { + it('moves the cursor before the first character of the next word', () => { + editor.setCursorBufferPosition([0, 6]) + editor.addCursorAtBufferPosition([1, 11]) + editor.addCursorAtBufferPosition([2, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToBeginningOfNextWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([1, 13]) + expect(cursor3.getBufferPosition()).toEqual([2, 4]) + + // When the cursor is on whitespace + editor.setText('ab cde- ') + editor.setCursorBufferPosition([0, 2]) + const cursor = editor.getLastCursor() + editor.moveToBeginningOfNextWord() + + expect(cursor.getBufferPosition()).toEqual([0, 3]) + }) + + it('does not blow up when there is no next word', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + const endPosition = editor.getCursorBufferPosition() + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual(endPosition) + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([9, 4]) + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 9]) + }) + }) + + describe('.moveToPreviousSubwordBoundary', () => { + it('does not move the cursor when there is no previous subword boundary', () => { + editor.setText('') + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('stops at word and underscore boundaries', () => { + editor.setText('sub_word \n') + editor.setCursorBufferPosition([0, 9]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 8]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText(' word\n') + editor.setCursorBufferPosition([0, 3]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('stops at camelCase boundaries', () => { + editor.setText(' getPreviousWord\n') + editor.setCursorBufferPosition([0, 16]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 12]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('skips consecutive non-word characters', () => { + editor.setText('e, => \n') + editor.setCursorBufferPosition([0, 6]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('skips consecutive uppercase characters', () => { + editor.setText(' AAADF \n') + editor.setCursorBufferPosition([0, 7]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.setText('ALPhA\n') + editor.setCursorBufferPosition([0, 4]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + + it('skips consecutive numbers', () => { + editor.setText(' 88 \n') + editor.setCursorBufferPosition([0, 4]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('works with multiple cursors', () => { + editor.setText('curOp\ncursorOptions\n') + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([1, 13]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveToPreviousSubwordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 3]) + expect(cursor2.getBufferPosition()).toEqual([1, 6]) + }) + + it('works with non-English characters', () => { + editor.setText('supåTøåst \n') + editor.setCursorBufferPosition([0, 9]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.setText('supaÖast \n') + editor.setCursorBufferPosition([0, 8]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + }) + + describe('.moveToNextSubwordBoundary', () => { + it('does not move the cursor when there is no next subword boundary', () => { + editor.setText('') + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('stops at word and underscore boundaries', () => { + editor.setText(' sub_word \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 9]) + + editor.setText('word \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + + it('stops at camelCase boundaries', () => { + editor.setText('getPreviousWord \n') + editor.setCursorBufferPosition([0, 0]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 11]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 15]) + }) + + it('skips consecutive non-word characters', () => { + editor.setText(', => \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + + it('skips consecutive uppercase characters', () => { + editor.setText(' AAADF \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + editor.setText('ALPhA\n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + + it('skips consecutive numbers', () => { + editor.setText(' 88 \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + }) + + it('works with multiple cursors', () => { + editor.setText('curOp\ncursorOptions\n') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 0]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveToNextSubwordBoundary() + expect(cursor1.getBufferPosition()).toEqual([0, 3]) + expect(cursor2.getBufferPosition()).toEqual([1, 6]) + }) + + it('works with non-English characters', () => { + editor.setText('supåTøåst \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.setText('supaÖast \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + }) + + describe('.moveToBeginningOfNextParagraph()', () => { + it('moves the cursor before the first line of the next paragraph', () => { + editor.setCursorBufferPosition([0, 6]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('moves the cursor before the first line of the next paragraph (CRLF line endings)', () => { + editor.setText(editor.getText().replace(/\n/g, '\r\n')) + + editor.setCursorBufferPosition([0, 6]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToBeginningOfPreviousParagraph()', () => { + it('moves the cursor before the first line of the previous paragraph', () => { + editor.setCursorBufferPosition([10, 0]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('moves the cursor before the first line of the previous paragraph (CRLF line endings)', () => { + editor.setText(editor.getText().replace(/\n/g, '\r\n')) + + editor.setCursorBufferPosition([10, 0]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.getCurrentParagraphBufferRange()', () => { + it('returns the buffer range of the current paragraph, delimited by blank lines or the beginning / end of the file', () => { + buffer.setText(' ' + dedent` + I am the first paragraph, + bordered by the beginning of + the file + ${' '} + + I am the second paragraph + with blank lines above and below + me. + + I am the last paragraph, + bordered by the end of the file.\ + `) + + // in a paragraph + editor.setCursorBufferPosition([1, 7]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[0, 0], [2, 8]]) + + editor.setCursorBufferPosition([7, 1]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[5, 0], [7, 3]]) + + editor.setCursorBufferPosition([9, 10]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[9, 0], [10, 32]]) + + // between paragraphs + editor.setCursorBufferPosition([3, 1]) + expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() + }) + + it('will limit paragraph range to comments', () => { + editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + editor.setText(dedent` + var quicksort = function () { + /* Single line comment block */ + var sort = function(items) {}; + + /* + A multiline + comment is here + */ + var sort = function(items) {}; + + // A comment + // + // Multiple comment + // lines + var sort = function(items) {}; + // comment line after fn + + var nosort = function(items) { + item; + } + + };\ + `) + + function paragraphBufferRangeForRow (row) { + editor.setCursorBufferPosition([row, 0]) + return editor.getLastCursor().getCurrentParagraphBufferRange() + } + + expect(paragraphBufferRangeForRow(0)).toEqual([[0, 0], [0, 29]]) + expect(paragraphBufferRangeForRow(1)).toEqual([[1, 0], [1, 33]]) + expect(paragraphBufferRangeForRow(2)).toEqual([[2, 0], [2, 32]]) + expect(paragraphBufferRangeForRow(3)).toBeFalsy() + expect(paragraphBufferRangeForRow(4)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(5)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(6)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(7)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(8)).toEqual([[8, 0], [8, 32]]) + expect(paragraphBufferRangeForRow(9)).toBeFalsy() + expect(paragraphBufferRangeForRow(10)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(11)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(12)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(14)).toEqual([[14, 0], [14, 32]]) + expect(paragraphBufferRangeForRow(15)).toEqual([[15, 0], [15, 26]]) + expect(paragraphBufferRangeForRow(18)).toEqual([[17, 0], [19, 3]]) + }) + }) + + describe('getCursorAtScreenPosition(screenPosition)', () => { + it('returns the cursor at the given screenPosition', () => { + const cursor1 = editor.addCursorAtScreenPosition([0, 2]) + const cursor2 = editor.getCursorAtScreenPosition(cursor1.getScreenPosition()) + expect(cursor2).toBe(cursor1) + }) + }) + + describe('::getCursorScreenPositions()', () => { + it('returns the cursor positions in the order they were added', () => { + editor.foldBufferRow(4) + const cursor1 = editor.addCursorAtBufferPosition([8, 5]) + const cursor2 = editor.addCursorAtBufferPosition([3, 5]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [5, 5], [3, 5]]) + }) + }) + + describe('::getCursorsOrderedByBufferPosition()', () => { + it('returns all cursors ordered by buffer positions', () => { + const originalCursor = editor.getLastCursor() + const cursor1 = editor.addCursorAtBufferPosition([8, 5]) + const cursor2 = editor.addCursorAtBufferPosition([4, 5]) + expect(editor.getCursorsOrderedByBufferPosition()).toEqual([originalCursor, cursor2, cursor1]) + }) + }) + + describe('addCursorAtScreenPosition(screenPosition)', () => { + describe('when a cursor already exists at the position', () => { + it('returns the existing cursor', () => { + const cursor1 = editor.addCursorAtScreenPosition([0, 2]) + const cursor2 = editor.addCursorAtScreenPosition([0, 2]) + expect(cursor2).toBe(cursor1) + }) + }) + }) + + describe('addCursorAtBufferPosition(bufferPosition)', () => { + describe('when a cursor already exists at the position', () => { + it('returns the existing cursor', () => { + const cursor1 = editor.addCursorAtBufferPosition([1, 4]) + const cursor2 = editor.addCursorAtBufferPosition([1, 4]) + expect(cursor2.marker).toBe(cursor1.marker) + }) + }) + }) + + describe('.getCursorScope()', () => { + it('returns the current scope', () => { + const descriptor = editor.getCursorScope() + expect(descriptor.scopes).toContain('source.js') + }) + }) + }) + + describe('selection', () => { + let selection + + beforeEach(() => { + selection = editor.getLastSelection() + }) + + describe('.getLastSelection()', () => { + it('creates a new selection at (0, 0) if the last selection has been destroyed', () => { + editor.getLastSelection().destroy() + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + + it("doesn't get stuck in a infinite loop when called from ::onDidAddCursor after the last selection has been destroyed (regression)", () => { + let callCount = 0 + editor.getLastSelection().destroy() + editor.onDidAddCursor(function (cursor) { + callCount++ + editor.getLastSelection() + }) + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) + expect(callCount).toBe(1) + }) + }) + + describe('.getSelections()', () => { + it('creates a new selection at (0, 0) if the last selection has been destroyed', () => { + editor.getLastSelection().destroy() + expect(editor.getSelections()[0].getBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + }) + + describe('when the selection range changes', () => { + it('emits an event with the old range, new range, and the selection that moved', () => { + let rangeChangedHandler + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + + editor.onDidChangeSelectionRange(rangeChangedHandler = jasmine.createSpy()) + editor.selectToBufferPosition([6, 2]) + + expect(rangeChangedHandler).toHaveBeenCalled() + const eventObject = rangeChangedHandler.mostRecentCall.args[0] + + expect(eventObject.oldBufferRange).toEqual([[3, 0], [4, 5]]) + expect(eventObject.oldScreenRange).toEqual([[3, 0], [4, 5]]) + expect(eventObject.newBufferRange).toEqual([[3, 0], [6, 2]]) + expect(eventObject.newScreenRange).toEqual([[3, 0], [6, 2]]) + expect(eventObject.selection).toBe(selection) + }) + }) + + describe('.selectUp/Down/Left/Right()', () => { + it("expands each selection to its cursor's new location", () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 14]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 22]]) + + editor.selectLeft() + editor.selectLeft() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + + editor.selectDown() + expect(selection1.getBufferRange()).toEqual([[0, 9], [1, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [4, 20]]) + + editor.selectUp() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + }) + + it('merges selections when they intersect when moving down', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]], [[2, 15], [3, 25]]]) + const [selection1, selection2, selection3] = editor.getSelections() + + editor.selectDown() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 9], [4, 25]]) + expect(selection1.isReversed()).toBeFalsy() + }) + + it('merges selections when they intersect when moving up', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]]], {reversed: true}) + const [selection1, selection2] = editor.getSelections() + + editor.selectUp() + expect(editor.getSelections().length).toBe(1) + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 0], [1, 20]]) + expect(selection1.isReversed()).toBeTruthy() + }) + + it('merges selections when they intersect when moving left', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[0, 13], [1, 20]]], {reversed: true}) + const [selection1, selection2] = editor.getSelections() + + editor.selectLeft() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 8], [1, 20]]) + expect(selection1.isReversed()).toBeTruthy() + }) + + it('merges selections when they intersect when moving right', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 14]], [[0, 14], [1, 20]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 9], [1, 21]]) + expect(selection1.isReversed()).toBeFalsy() + }) + + describe('when counts are passed into the selection functions', () => { + it("expands each selection to its cursor's new location", () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight(2) + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 15]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 23]]) + + editor.selectLeft(3) + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + + editor.selectDown(3) + expect(selection1.getBufferRange()).toEqual([[0, 9], [3, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [6, 20]]) + + editor.selectUp(2) + expect(selection1.getBufferRange()).toEqual([[0, 9], [1, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [4, 20]]) + }) + }) + }) + + describe('.selectToBufferPosition(bufferPosition)', () => { + it('expands the last selection to the given position', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtBufferPosition([5, 6]) + editor.selectToBufferPosition([6, 2]) + + const selections = editor.getSelections() + expect(selections.length).toBe(2) + const [selection1, selection2] = selections + expect(selection1.getBufferRange()).toEqual([[3, 0], [4, 5]]) + expect(selection2.getBufferRange()).toEqual([[5, 6], [6, 2]]) + }) + }) + + describe('.selectToScreenPosition(screenPosition)', () => { + it('expands the last selection to the given position', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + const selections = editor.getSelections() + expect(selections.length).toBe(2) + const [selection1, selection2] = selections + expect(selection1.getScreenRange()).toEqual([[3, 0], [4, 5]]) + expect(selection2.getScreenRange()).toEqual([[5, 6], [6, 2]]) + }) + + describe('when selecting with an initial screen range', () => { + it('switches the direction of the selection when selecting to positions before/after the start of the initial range', () => { + editor.setCursorScreenPosition([5, 10]) + editor.selectWordsContainingCursors() + editor.selectToScreenPosition([3, 0]) + expect(editor.getLastSelection().isReversed()).toBe(true) + editor.selectToScreenPosition([9, 0]) + expect(editor.getLastSelection().isReversed()).toBe(false) + }) + }) + }) + + describe('.selectToBeginningOfNextParagraph()', () => { + it('selects from the cursor to first line of the next paragraph', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + editor.selectToBeginningOfNextParagraph() + + const selections = editor.getSelections() + expect(selections.length).toBe(1) + expect(selections[0].getScreenRange()).toEqual([[3, 0], [10, 0]]) + }) + }) + + describe('.selectToBeginningOfPreviousParagraph()', () => { + it('selects from the cursor to the first line of the previous paragraph', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + editor.selectToBeginningOfPreviousParagraph() + + const selections = editor.getSelections() + expect(selections.length).toBe(1) + expect(selections[0].getScreenRange()).toEqual([[0, 0], [5, 6]]) + }) + + it('merges selections if they intersect, maintaining the directionality of the last selection', () => { + editor.setCursorScreenPosition([4, 10]) + editor.selectToScreenPosition([5, 27]) + editor.addCursorAtScreenPosition([3, 10]) + editor.selectToScreenPosition([6, 27]) + + let selections = editor.getSelections() + expect(selections.length).toBe(1) + let [selection1] = selections + expect(selection1.getScreenRange()).toEqual([[3, 10], [6, 27]]) + expect(selection1.isReversed()).toBeFalsy() + + editor.addCursorAtScreenPosition([7, 4]) + editor.selectToScreenPosition([4, 11]) + + selections = editor.getSelections() + expect(selections.length).toBe(1); + [selection1] = selections + expect(selection1.getScreenRange()).toEqual([[3, 10], [7, 4]]) + expect(selection1.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToTop()', () => { + it('selects text from cursor position to the top of the buffer', () => { + editor.setCursorScreenPosition([11, 2]) + editor.addCursorAtScreenPosition([10, 0]) + editor.selectToTop() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [11, 2]]) + expect(editor.getLastSelection().isReversed()).toBeTruthy() + }) + }) + + describe('.selectToBottom()', () => { + it('selects text from cursor position to the bottom of the buffer', () => { + editor.setCursorScreenPosition([10, 0]) + editor.addCursorAtScreenPosition([9, 3]) + editor.selectToBottom() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([12, 2]) + expect(editor.getLastSelection().getBufferRange()).toEqual([[9, 3], [12, 2]]) + expect(editor.getLastSelection().isReversed()).toBeFalsy() + }) + }) + + describe('.selectAll()', () => { + it('selects the entire buffer', () => { + editor.selectAll() + expect(editor.getLastSelection().getBufferRange()).toEqual(buffer.getRange()) + }) + }) + + describe('.selectToBeginningOfLine()', () => { + it('selects text from cursor position to beginning of line', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([11, 3]) + + editor.selectToBeginningOfLine() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([12, 0]) + expect(cursor2.getBufferPosition()).toEqual([11, 0]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[12, 0], [12, 2]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[11, 0], [11, 3]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToEndOfLine()', () => { + it('selects text from cursor position to end of line', () => { + editor.setCursorScreenPosition([12, 0]) + editor.addCursorAtScreenPosition([11, 3]) + + editor.selectToEndOfLine() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + expect(cursor2.getBufferPosition()).toEqual([11, 44]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[12, 0], [12, 2]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[11, 3], [11, 44]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectLinesContainingCursors()', () => { + it('selects to the entire line (including newlines) at given row', () => { + editor.setCursorScreenPosition([1, 2]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[1, 0], [2, 0]]) + expect(editor.getSelectedText()).toBe(' var sort = function(items) {\n') + + editor.setCursorScreenPosition([12, 2]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[12, 0], [12, 2]]) + + editor.setCursorBufferPosition([0, 2]) + editor.selectLinesContainingCursors() + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [2, 0]]) + }) + + describe('when the selection spans multiple row', () => { + it('selects from the beginning of the first line to the last line', () => { + selection = editor.getLastSelection() + selection.setBufferRange([[1, 10], [3, 20]]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[1, 0], [4, 0]]) + }) + }) + }) + + describe('.selectToBeginningOfWord()', () => { + it('selects text from cursor position to beginning of word', () => { + editor.setCursorScreenPosition([0, 13]) + editor.addCursorAtScreenPosition([3, 49]) + + editor.selectToBeginningOfWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([3, 47]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 13]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[3, 47], [3, 49]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToEndOfWord()', () => { + it('selects text from cursor position to end of word', () => { + editor.setCursorScreenPosition([0, 4]) + editor.addCursorAtScreenPosition([3, 48]) + + editor.selectToEndOfWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 50]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 13]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[3, 48], [3, 50]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToBeginningOfNextWord()', () => { + it('selects text from cursor position to beginning of next word', () => { + editor.setCursorScreenPosition([0, 4]) + editor.addCursorAtScreenPosition([3, 48]) + + editor.selectToBeginningOfNextWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([3, 51]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 14]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[3, 48], [3, 51]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToPreviousWordBoundary()', () => { + it('select to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 0]) + editor.addCursorAtBufferPosition([3, 4]) + editor.addCursorAtBufferPosition([3, 14]) + + editor.selectToPreviousWordBoundary() + + expect(editor.getSelections().length).toBe(4) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 8], [0, 4]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[2, 0], [1, 30]]) + expect(selection2.isReversed()).toBeTruthy() + expect(selection3.getBufferRange()).toEqual([[3, 4], [3, 0]]) + expect(selection3.isReversed()).toBeTruthy() + expect(selection4.getBufferRange()).toEqual([[3, 14], [3, 13]]) + expect(selection4.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToNextWordBoundary()', () => { + it('select to the next word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 40]) + editor.addCursorAtBufferPosition([4, 0]) + editor.addCursorAtBufferPosition([3, 30]) + + editor.selectToNextWordBoundary() + + expect(editor.getSelections().length).toBe(4) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 8], [0, 13]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[2, 40], [3, 0]]) + expect(selection2.isReversed()).toBeFalsy() + expect(selection3.getBufferRange()).toEqual([[4, 0], [4, 4]]) + expect(selection3.isReversed()).toBeFalsy() + expect(selection4.getBufferRange()).toEqual([[3, 30], [3, 31]]) + expect(selection4.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToPreviousSubwordBoundary', () => { + it('selects subwords', () => { + editor.setText('') + editor.insertText('_word\n') + editor.insertText(' getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 5]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 5]) + editor.addCursorAtBufferPosition([3, 3]) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + + editor.selectToPreviousSubwordBoundary() + expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 4], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + expect(selection3.getBufferRange()).toEqual([[2, 3], [2, 5]]) + expect(selection3.isReversed()).toBeTruthy() + expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) + expect(selection4.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToNextSubwordBoundary', () => { + it('selects subwords', () => { + editor.setText('') + editor.insertText('word_\n') + editor.insertText('getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 1]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 2]) + editor.addCursorAtBufferPosition([3, 1]) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + + editor.selectToNextSubwordBoundary() + expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 4]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[1, 7], [1, 11]]) + expect(selection2.isReversed()).toBeFalsy() + expect(selection3.getBufferRange()).toEqual([[2, 2], [2, 5]]) + expect(selection3.isReversed()).toBeFalsy() + expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) + expect(selection4.isReversed()).toBeFalsy() + }) + }) + + describe('.deleteToBeginningOfSubword', () => { + it('deletes subwords', () => { + editor.setText('') + editor.insertText('_word\n') + editor.insertText(' getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 5]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 5]) + editor.addCursorAtBufferPosition([3, 3]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('_') + expect(buffer.lineForRow(1)).toBe(' getviousWord') + expect(buffer.lineForRow(2)).toBe('e, ') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 1]) + expect(cursor2.getBufferPosition()).toEqual([1, 4]) + expect(cursor3.getBufferPosition()).toEqual([2, 3]) + expect(cursor4.getBufferPosition()).toEqual([3, 1]) + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe(' viousWord') + expect(buffer.lineForRow(2)).toBe('e ') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 1]) + expect(cursor3.getBufferPosition()).toEqual([2, 1]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe('viousWord') + expect(buffer.lineForRow(2)).toBe(' ') + expect(buffer.lineForRow(3)).toBe('') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 0]) + expect(cursor4.getBufferPosition()).toEqual([2, 1]) + }) + }) + + describe('.deleteToEndOfSubword', () => { + it('deletes subwords', () => { + editor.setText('') + editor.insertText('word_\n') + editor.insertText('getPreviousWord \n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 0]) + editor.addCursorAtBufferPosition([2, 2]) + editor.addCursorAtBufferPosition([3, 0]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.deleteToEndOfSubword() + expect(buffer.lineForRow(0)).toBe('_') + expect(buffer.lineForRow(1)).toBe('PreviousWord ') + expect(buffer.lineForRow(2)).toBe('e, ') + expect(buffer.lineForRow(3)).toBe('88 ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 2]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + + editor.deleteToEndOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe('Word ') + expect(buffer.lineForRow(2)).toBe('e,') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 2]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + }) + }) + + describe('.selectWordsContainingCursors()', () => { + describe('when the cursor is inside a word', () => { + it('selects the entire word', () => { + editor.setCursorScreenPosition([0, 8]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('quicksort') + }) + }) + + describe('when the cursor is between two words', () => { + it('selects the word the cursor is on', () => { + editor.setCursorBufferPosition([0, 4]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('quicksort') + + editor.setCursorBufferPosition([0, 3]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('var') + + editor.setCursorBufferPosition([1, 22]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('items') + }) + }) + + describe('when the cursor is inside a region of whitespace', () => { + it('selects the whitespace region', () => { + editor.setCursorScreenPosition([5, 2]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[5, 0], [5, 6]]) + + editor.setCursorScreenPosition([5, 0]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[5, 0], [5, 6]]) + }) + }) + + describe('when the cursor is at the end of the text', () => { + it('select the previous word', () => { + editor.buffer.append('word') + editor.moveToBottom() + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[12, 2], [12, 6]]) + }) + }) + + it("selects words based on the non-word characters configured at the cursor's current scope", () => { + editor.setText("one-one; 'two-two'; three-three") + + editor.setCursorBufferPosition([0, 1]) + editor.addCursorAtBufferPosition([0, 12]) + + const scopeDescriptors = editor.getCursors().map(c => c.getScopeDescriptor()) + expect(scopeDescriptors[0].getScopesArray()).toEqual(['source.js']) + expect(scopeDescriptors[1].getScopesArray()).toEqual(['source.js', 'string.quoted.single.js']) + + editor.setScopedSettingsDelegate({ + getNonWordCharacters (scopes) { + const result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' + if (scopes.some(scope => scope.startsWith('string'))) { + return result + } else { + return result + '-' + } + } + }) + + editor.selectWordsContainingCursors() + + expect(editor.getSelections()[0].getText()).toBe('one') + expect(editor.getSelections()[1].getText()).toBe('two-two') + }) + }) + + describe('.selectToFirstCharacterOfLine()', () => { + it("moves to the first character of the current line or the beginning of the line if it's already on the first character", () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + + editor.selectToFirstCharacterOfLine() + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 2]) + + expect(editor.getSelections().length).toBe(2) + let [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 0], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 2], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + + editor.selectToFirstCharacterOfLine(); + [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 0], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 0], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.setSelectedBufferRanges(ranges)', () => { + it('clears existing selections and creates selections for each of the given ranges', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + + editor.setSelectedBufferRanges([[[5, 5], [6, 6]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[5, 5], [6, 6]]]) + }) + + it('merges intersecting selections', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [5, 5]]]) + }) + + it('does not merge non-empty adjacent selections', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) + }) + + it('recycles existing selection instances', () => { + selection = editor.getLastSelection() + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1).toBe(selection) + expect(selection1.getBufferRange()).toEqual([[2, 2], [3, 3]]) + }) + + describe("when the 'preserveFolds' option is false (the default)", () => { + it("removes folds that contain one or both of the selection's end points", () => { + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + editor.foldBufferRowRange(1, 4) + editor.foldBufferRowRange(2, 3) + editor.foldBufferRowRange(6, 8) + editor.foldBufferRowRange(10, 11) + + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 6], [7, 7]]]) + expect(editor.isFoldedAtScreenRow(1)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(6)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() + + editor.setSelectedBufferRange([[10, 0], [12, 0]]) + expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() + }) + }) + + describe("when the 'preserveFolds' option is true", () => { + it('does not remove folds that contain the selections', () => { + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + editor.foldBufferRowRange(1, 4) + editor.foldBufferRowRange(6, 8) + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 0], [6, 1]]], {preserveFolds: true}) + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + }) + }) + }) + + describe('.setSelectedScreenRanges(ranges)', () => { + beforeEach(() => editor.foldBufferRow(4)) + + it('clears existing selections and creates selections for each of the given ranges', () => { + editor.setSelectedScreenRanges([[[3, 4], [3, 7]], [[5, 4], [5, 7]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[3, 4], [3, 7]], [[8, 4], [8, 7]]]) + + editor.setSelectedScreenRanges([[[6, 2], [6, 4]]]) + expect(editor.getSelectedScreenRanges()).toEqual([[[6, 2], [6, 4]]]) + }) + + it('merges intersecting selections and unfolds the fold which contain them', () => { + editor.foldBufferRow(0) + + // Use buffer ranges because only the first line is on screen + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [5, 5]]]) + }) + + it('recycles existing selection instances', () => { + selection = editor.getLastSelection() + editor.setSelectedScreenRanges([[[2, 2], [3, 4]], [[4, 4], [5, 5]]]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1).toBe(selection) + expect(selection1.getScreenRange()).toEqual([[2, 2], [3, 4]]) + }) + }) + + describe('.selectMarker(marker)', () => { + describe('if the marker is valid', () => { + it("selects the marker's range and returns the selected range", () => { + const marker = editor.markBufferRange([[0, 1], [3, 3]]) + expect(editor.selectMarker(marker)).toEqual([[0, 1], [3, 3]]) + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 3]]) + }) + }) + + describe('if the marker is invalid', () => { + it('does not change the selection and returns a falsy value', () => { + const marker = editor.markBufferRange([[0, 1], [3, 3]]) + marker.destroy() + expect(editor.selectMarker(marker)).toBeFalsy() + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + }) + }) + + describe('.addSelectionForBufferRange(bufferRange)', () => { + it('adds a selection for the specified buffer range', () => { + editor.addSelectionForBufferRange([[3, 4], [5, 6]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 0]], [[3, 4], [5, 6]]]) + }) + }) + + describe('.addSelectionBelow()', () => { + describe('when the selection is non-empty', () => { + it('selects the same region of the line below current selections if possible', () => { + editor.setSelectedBufferRange([[3, 16], [3, 21]]) + editor.addSelectionForBufferRange([[3, 25], [3, 34]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 16], [3, 21]], + [[3, 25], [3, 34]], + [[4, 16], [4, 21]], + [[4, 25], [4, 29]] + ]) + }) + + it('skips lines that are too short to create a non-empty selection', () => { + editor.setSelectedBufferRange([[3, 31], [3, 38]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 31], [3, 38]], + [[6, 31], [6, 38]] + ]) + }) + + it("honors the original selection's range (goal range) when adding across shorter lines", () => { + editor.setSelectedBufferRange([[3, 22], [3, 38]]) + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 38]], + [[4, 22], [4, 29]], + [[5, 22], [5, 30]], + [[6, 22], [6, 38]] + ]) + }) + + it('clears selection goal ranges when the selection changes', () => { + editor.setSelectedBufferRange([[3, 22], [3, 38]]) + editor.addSelectionBelow() + editor.selectLeft() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 37]], + [[4, 22], [4, 29]], + [[5, 22], [5, 28]] + ]) + + // goal range from previous add selection is honored next time + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 37]], + [[4, 22], [4, 29]], + [[5, 22], [5, 30]], // select to end of line 5 because line 4's goal range was reset by line 3 previously + [[6, 22], [6, 28]] + ]) + }) + + it('can add selections to soft-wrapped line segments', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(40) + editor.setDefaultCharWidth(1) + + editor.setSelectedScreenRange([[3, 10], [3, 15]]) + editor.addSelectionBelow() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 10], [3, 15]], + [[4, 10], [4, 15]] + ]) + }) + + it('takes atomic tokens into account', async () => { + editor = await atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', {autoIndent: false}) + editor.setSelectedBufferRange([[2, 1], [2, 3]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 1], [2, 3]], + [[3, 1], [3, 2]] + ]) + }) + }) + + describe('when the selection is empty', () => { + describe('when lines are soft-wrapped', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + }) + + it('skips soft-wrap indentation tokens', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addSelectionBelow() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 0], [3, 0]], + [[4, 4], [4, 4]] + ]) + }) + + it("does not skip them if they're shorter than the current column", () => { + editor.setCursorScreenPosition([3, 37]) + editor.addSelectionBelow() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 37], [3, 37]], + [[4, 26], [4, 26]] + ]) + }) + }) + + it('does not skip lines that are shorter than the current column', () => { + editor.setCursorBufferPosition([3, 36]) + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 36], [3, 36]], + [[4, 29], [4, 29]], + [[5, 30], [5, 30]], + [[6, 36], [6, 36]] + ]) + }) + + it('skips empty lines when the column is non-zero', () => { + editor.setCursorBufferPosition([9, 4]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[9, 4], [9, 4]], + [[11, 4], [11, 4]] + ]) + }) + + it('does not skip empty lines when the column is zero', () => { + editor.setCursorBufferPosition([9, 0]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[9, 0], [9, 0]], + [[10, 0], [10, 0]] + ]) + }) + }) + }) + + describe('.addSelectionAbove()', () => { + describe('when the selection is non-empty', () => { + it('selects the same region of the line above current selections if possible', () => { + editor.setSelectedBufferRange([[3, 16], [3, 21]]) + editor.addSelectionForBufferRange([[3, 37], [3, 44]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 16], [3, 21]], + [[3, 37], [3, 44]], + [[2, 16], [2, 21]], + [[2, 37], [2, 40]] + ]) + }) + + it('skips lines that are too short to create a non-empty selection', () => { + editor.setSelectedBufferRange([[6, 31], [6, 38]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 31], [6, 38]], + [[3, 31], [3, 38]] + ]) + }) + + it("honors the original selection's range (goal range) when adding across shorter lines", () => { + editor.setSelectedBufferRange([[6, 22], [6, 38]]) + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 22], [6, 38]], + [[5, 22], [5, 30]], + [[4, 22], [4, 29]], + [[3, 22], [3, 38]] + ]) + }) + + it('can add selections to soft-wrapped line segments', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + + editor.setSelectedScreenRange([[4, 10], [4, 15]]) + editor.addSelectionAbove() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[4, 10], [4, 15]], + [[3, 10], [3, 15]] + ]) + }) + + it('takes atomic tokens into account', async () => { + editor = await atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', {autoIndent: false}) + editor.setSelectedBufferRange([[3, 1], [3, 2]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 1], [3, 2]], + [[2, 1], [2, 3]] + ]) + }) + }) + + describe('when the selection is empty', () => { + describe('when lines are soft-wrapped', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + }) + + it('skips soft-wrap indentation tokens', () => { + editor.setCursorScreenPosition([5, 0]) + editor.addSelectionAbove() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[5, 0], [5, 0]], + [[4, 4], [4, 4]] + ]) + }) + + it("does not skip them if they're shorter than the current column", () => { + editor.setCursorScreenPosition([5, 29]) + editor.addSelectionAbove() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[5, 29], [5, 29]], + [[4, 26], [4, 26]] + ]) + }) + }) + + it('does not skip lines that are shorter than the current column', () => { + editor.setCursorBufferPosition([6, 36]) + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 36], [6, 36]], + [[5, 30], [5, 30]], + [[4, 29], [4, 29]], + [[3, 36], [3, 36]] + ]) + }) + + it('skips empty lines when the column is non-zero', () => { + editor.setCursorBufferPosition([11, 4]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[11, 4], [11, 4]], + [[9, 4], [9, 4]] + ]) + }) + + it('does not skip empty lines when the column is zero', () => { + editor.setCursorBufferPosition([10, 0]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[10, 0], [10, 0]], + [[9, 0], [9, 0]] + ]) + }) + }) + }) + + describe('.splitSelectionsIntoLines()', () => { + it('splits all multi-line selections into one selection per line', () => { + editor.setSelectedBufferRange([[0, 3], [2, 4]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 29]], + [[1, 0], [1, 30]], + [[2, 0], [2, 4]] + ]) + + editor.setSelectedBufferRange([[0, 3], [1, 10]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 29]], + [[1, 0], [1, 10]] + ]) + + editor.setSelectedBufferRange([[0, 0], [0, 3]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]]]) + }) + }) + + describe('::consolidateSelections()', () => { + const makeMultipleSelections = () => { + selection.setBufferRange([[3, 16], [3, 21]]) + const selection2 = editor.addSelectionForBufferRange([[3, 25], [3, 34]]) + const selection3 = editor.addSelectionForBufferRange([[8, 4], [8, 10]]) + const selection4 = editor.addSelectionForBufferRange([[1, 6], [1, 10]]) + expect(editor.getSelections()).toEqual([selection, selection2, selection3, selection4]) + return [selection, selection2, selection3, selection4] + } + + it('destroys all selections but the oldest selection and autoscrolls to it, returning true if any selections were destroyed', () => { + const [selection1] = makeMultipleSelections() + + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) + + expect(editor.consolidateSelections()).toBeTruthy() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.isEmpty()).toBeFalsy() + expect(editor.consolidateSelections()).toBeFalsy() + expect(editor.getSelections()).toEqual([selection1]) + + expect(autoscrollEvents).toEqual([ + {screenRange: selection1.getScreenRange(), options: {center: true, reversed: false}} + ]) + }) + }) + + describe('when the cursor is moved while there is a selection', () => { + const makeSelection = () => selection.setBufferRange([[1, 2], [1, 5]]) + + it('clears the selection', () => { + makeSelection() + editor.moveDown() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveUp() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveLeft() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveRight() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.setCursorScreenPosition([3, 3]) + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + it('does not share selections between different edit sessions for the same buffer', async () => { + atom.workspace.getActivePane().splitRight() + const editor2 = await atom.workspace.open(editor.getPath()) + + expect(editor2.getText()).toBe(editor.getText()) + editor.setSelectedBufferRanges([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) + editor2.setSelectedBufferRanges([[[8, 7], [6, 5]], [[4, 3], [2, 1]]]) + expect(editor2.getSelectedBufferRanges()).not.toEqual(editor.getSelectedBufferRanges()) + }) + }) + + describe('buffer manipulation', () => { + describe('.moveLineUp', () => { + it('moves the line under the cursor up', () => { + editor.setCursorBufferPosition([1, 0]) + editor.moveLineUp() + expect(editor.getTextInBufferRange([[0, 0], [0, 30]])).toBe(' var sort = function(items) {') + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + it("updates the line's indentation when the the autoIndent setting is true", () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([1, 0]) + editor.moveLineUp() + expect(editor.indentationForBufferRow(0)).toBe(0) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + describe('when there is a single selection', () => { + describe('when the selection spans a single line', () => { + describe('when there is no fold in the preceeding row', () => + it('moves the line to the preceding row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.setSelectedBufferRange([[3, 2], [3, 9]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [2, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is at the beginning of a fold', () => + it('moves the line to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [4, 9]], {preserveFolds: true}) + expect(editor.getSelectedBufferRange()).toEqual([[4, 2], [4, 9]]) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + }) + ) + + describe('when the preceding row consists of folded code', () => + it('moves the line above the folded row and perseveres the correct folds', () => { + expect(editor.lineTextForBufferRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[8, 0], [8, 4]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [4, 4]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when the selection spans multiple lines', () => { + it('moves the lines spanned by the selection to the preceding row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[3, 2], [4, 9]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' if (items.length <= 1) return items;') + }) + + describe("when the selection's end intersects a fold", () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[3, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' if (items.length <= 1) return items;') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + }) + ) + + describe("when the selection's start intersects a fold", () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [8, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [7, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(8)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + }) + ) + }) + + describe('when the selection spans multiple lines, but ends at column 0', () => { + it('does not move the last line of the selection', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[3, 2], [4, 0]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 0]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + }) + }) + + describe('when the preceeding row is a folded row', () => { + it('moves the lines spanned by the selection to the preceeding row, but preserves the folded code', () => { + expect(editor.lineTextForBufferRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + editor.foldBufferRowRange(4, 7) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[8, 0], [9, 2]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [5, 2]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' };') + expect(editor.lineTextForBufferRow(6)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(5)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + }) + }) + }) + + describe('when there are multiple selections', () => { + describe('when all the selections span different lines', () => { + describe('when there is no folds', () => + it('moves all lines that are spanned by a selection to the preceding row', () => { + editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [0, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + expect(editor.lineTextForBufferRow(0)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(1)).toBe('var quicksort = function () {') + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + }) + ) + + describe('when one selection intersects a fold', () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRanges([ + [[2, 2], [2, 9]], + [[4, 2], [4, 9]] + ], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 2], [1, 9]], + [[3, 2], [3, 9]] + ]) + + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + }) + ) + + describe('when there is a fold', () => + it('moves all lines that spanned by a selection to preceding row, preserving all folds', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[8, 0], [8, 3]], [[11, 0], [11, 5]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[4, 0], [4, 3]], [[10, 0], [10, 5]]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when there are many folds', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample-with-many-folds.js', {autoIndent: false}) + }) + + describe('and many selections intersects folded rows', () => + it('moves and preserves all the folds', () => { + editor.foldBufferRowRange(2, 4) + editor.foldBufferRowRange(7, 9) + + editor.setSelectedBufferRanges([ + [[1, 0], [5, 4]], + [[7, 0], [7, 4]] + ], {preserveFolds: true}) + + editor.moveLineUp() + + expect(editor.lineTextForBufferRow(1)).toEqual('function f3() {') + expect(editor.lineTextForBufferRow(4)).toEqual('6;') + expect(editor.lineTextForBufferRow(5)).toEqual('1;') + expect(editor.lineTextForBufferRow(6)).toEqual('function f8() {') + expect(editor.lineTextForBufferRow(9)).toEqual('7;') + + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when some of the selections span the same lines', () => { + it('moves lines that contain multiple selections correctly', () => { + editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [2, 9]], [[2, 12], [2, 13]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + + describe('when one of the selections spans line 0', () => { + it("doesn't move any lines, since line 0 can't move", () => { + editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + expect(buffer.isModified()).toBe(false) + }) + }) + + describe('when one of the selections spans the last line, and it is empty', () => { + it("doesn't move any lines, since the last line can't move", () => { + buffer.append('\n') + editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) + }) + }) + }) + }) + + describe('.moveLineDown', () => { + it('moves the line under the cursor down', () => { + editor.setCursorBufferPosition([0, 0]) + editor.moveLineDown() + expect(editor.getTextInBufferRange([[1, 0], [1, 31]])).toBe('var quicksort = function () {') + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + it("updates the line's indentation when the editor.autoIndent setting is true", () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([0, 0]) + editor.moveLineDown() + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(2) + }) + + describe('when there is a single selection', () => { + describe('when the selection spans a single line', () => { + describe('when there is no fold in the following row', () => + it('moves the line to the following row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.setSelectedBufferRange([[2, 2], [2, 9]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is at the beginning of a fold', () => + it('moves the line to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[5, 2], [5, 9]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + + describe('when the following row is a folded row', () => + it('moves the line below the folded row and preserves the fold', () => { + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[3, 0], [3, 4]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[7, 0], [7, 4]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + ) + }) + + describe('when the selection spans multiple lines', () => { + it('moves the lines spanned by the selection to the following row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[2, 2], [3, 9]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [4, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + + describe('when the selection spans multiple lines, but ends at column 0', () => { + it('does not move the last line of the selection', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[2, 2], [3, 0]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [4, 0]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + }) + }) + + describe("when the selection's end intersects a fold", () => { + it('moves the lines to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[3, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 2], [5, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + }) + + describe("when the selection's start intersects a fold", () => { + it('moves the lines to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [8, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[5, 2], [9, 9]]) + expect(editor.lineTextForBufferRow(4)).toBe(' };') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(9)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + }) + }) + + describe('when the following row is a folded row', () => { + it('moves the lines spanned by the selection to the following row, but preserves the folded code', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.foldBufferRowRange(4, 7) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[2, 0], [3, 2]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[6, 0], [7, 2]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + }) + }) + + describe('when the last line of selection does not end with a valid line ending', () => { + it('appends line ending to last line and moves the lines spanned by the selection to the preceeding row', () => { + expect(editor.lineTextForBufferRow(9)).toBe(' };') + expect(editor.lineTextForBufferRow(10)).toBe('') + expect(editor.lineTextForBufferRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.lineTextForBufferRow(12)).toBe('};') + + editor.setSelectedBufferRange([[10, 0], [12, 2]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[9, 0], [11, 2]]) + expect(editor.lineTextForBufferRow(9)).toBe('') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.lineTextForBufferRow(11)).toBe('};') + expect(editor.lineTextForBufferRow(12)).toBe(' };') + }) + }) + }) + + describe('when there are multiple selections', () => { + describe('when all the selections span different lines', () => { + describe('when there is no folds', () => + it('moves all lines that are spanned by a selection to the following row', () => { + editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[6, 2], [6, 9]], [[4, 2], [4, 9]], [[2, 2], [2, 9]]]) + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(6)).toBe(' current = items.shift();') + }) + ) + + describe('when there are many folds', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample-with-many-folds.js', {autoIndent: false}) + }) + + describe('and many selections intersects folded rows', () => + it('moves and preserves all the folds', () => { + editor.foldBufferRowRange(2, 4) + editor.foldBufferRowRange(7, 9) + + editor.setSelectedBufferRanges([ + [[2, 0], [2, 4]], + [[6, 0], [10, 4]] + ], {preserveFolds: true}) + + editor.moveLineDown() + + expect(editor.lineTextForBufferRow(2)).toEqual('6;') + expect(editor.lineTextForBufferRow(3)).toEqual('function f3() {') + expect(editor.lineTextForBufferRow(6)).toEqual('12;') + expect(editor.lineTextForBufferRow(7)).toEqual('7;') + expect(editor.lineTextForBufferRow(8)).toEqual('function f8() {') + expect(editor.lineTextForBufferRow(11)).toEqual('11;') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(11)).toBeFalsy() + }) + ) + }) + + describe('when there is a fold below one of the selected row', () => + it('moves all lines spanned by a selection to the following row, preserving the fold', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[1, 2], [1, 6]], [[3, 0], [3, 4]], [[8, 0], [8, 3]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[9, 0], [9, 3]], [[7, 0], [7, 4]], [[2, 2], [2, 6]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(9)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + }) + ) + + describe('when there is a fold below a group of multiple selections without any lines with no selection in-between', () => + it('moves all the lines below the fold, preserving the fold', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[2, 2], [2, 6]], [[3, 0], [3, 4]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[7, 0], [7, 4]], [[6, 2], [6, 6]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + ) + }) + + describe('when one selection intersects a fold', () => { + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRanges([ + [[2, 2], [2, 9]], + [[4, 2], [4, 9]] + ], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[5, 2], [5, 9]], + [[3, 2], [3, 9]] + ]) + + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + }) + + describe('when some of the selections span the same lines', () => { + it('moves lines that contain multiple selections correctly', () => { + editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[4, 12], [4, 13]], [[4, 2], [4, 9]]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + }) + }) + + describe('when the selections are above a wrapped line', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(80) + editor.setText(`\ +1 +2 +Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. +3 +4\ +`) + }) + + it('moves the lines past the soft wrapped line', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 0]]]) + + editor.moveLineDown() + + expect(editor.lineTextForBufferRow(0)).not.toBe('2') + expect(editor.lineTextForBufferRow(1)).toBe('1') + expect(editor.lineTextForBufferRow(2)).toBe('2') + }) + }) + }) + + describe('when the line is the last buffer row', () => { + it("doesn't move it", () => { + editor.setText('abc\ndef') + editor.setCursorBufferPosition([1, 0]) + editor.moveLineDown() + expect(editor.getText()).toBe('abc\ndef') + }) + }) + }) + + describe('.insertText(text)', () => { + describe('when there is a single selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 0], [1, 2]])) + + it('replaces the selection with the given text', () => { + const range = editor.insertText('xxx') + expect(range).toEqual([ [[1, 0], [1, 3]] ]) + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + }) + }) + + describe('when there are multiple empty selections', () => { + describe('when the cursors are on the same line', () => { + it("inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", () => { + editor.setCursorScreenPosition([1, 2]) + editor.addCursorAtScreenPosition([1, 5]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe(' xxxvarxxx sort = function(items) {') + const [cursor1, cursor2] = editor.getCursors() + + expect(cursor1.getBufferPosition()).toEqual([1, 5]) + expect(cursor2.getBufferPosition()).toEqual([1, 11]) + }) + }) + + describe('when the cursors are on different lines', () => { + it("inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", () => { + editor.setCursorScreenPosition([1, 2]) + editor.addCursorAtScreenPosition([2, 4]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe(' xxxvar sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' xxxif (items.length <= 1) return items;') + const [cursor1, cursor2] = editor.getCursors() + + expect(cursor1.getBufferPosition()).toEqual([1, 5]) + expect(cursor2.getBufferPosition()).toEqual([2, 7]) + }) + }) + }) + + describe('when there are multiple non-empty selections', () => { + describe('when the selections are on the same line', () => { + it('replaces each selection range with the inserted characters', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 22], [0, 24]]]) + editor.insertText('x') + + const [cursor1, cursor2] = editor.getCursors() + const [selection1, selection2] = editor.getSelections() + + expect(cursor1.getScreenPosition()).toEqual([0, 5]) + expect(cursor2.getScreenPosition()).toEqual([0, 15]) + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + + expect(editor.lineTextForBufferRow(0)).toBe('var x = functix () {') + }) + }) + + describe('when the selections are on different lines', () => { + it("replaces each selection with the given text, clears the selections, and places the cursor at the end of each selection's inserted text", () => { + editor.setSelectedBufferRanges([[[1, 0], [1, 2]], [[2, 0], [2, 4]]]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + expect(buffer.lineForRow(2)).toBe('xxxif (items.length <= 1) return items;') + const [selection1, selection2] = editor.getSelections() + + expect(selection1.isEmpty()).toBeTruthy() + expect(selection1.cursor.getBufferPosition()).toEqual([1, 3]) + expect(selection2.isEmpty()).toBeTruthy() + expect(selection2.cursor.getBufferPosition()).toEqual([2, 3]) + }) + }) + }) + + describe('when there is a selection that ends on a folded line', () => { + it('destroys the selection', () => { + editor.foldBufferRowRange(2, 4) + editor.setSelectedBufferRange([[1, 0], [2, 0]]) + editor.insertText('holy cow') + expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() + }) + }) + + describe('when there are ::onWillInsertText and ::onDidInsertText observers', () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 0], [1, 2]])) + + it('notifies the observers when inserting text', () => { + const willInsertSpy = jasmine.createSpy().andCallFake(() => expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {')) + + const didInsertSpy = jasmine.createSpy().andCallFake(() => expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {')) + + editor.onWillInsertText(willInsertSpy) + editor.onDidInsertText(didInsertSpy) + + expect(editor.insertText('xxx')).toBeTruthy() + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + + expect(willInsertSpy).toHaveBeenCalled() + expect(didInsertSpy).toHaveBeenCalled() + + let options = willInsertSpy.mostRecentCall.args[0] + expect(options.text).toBe('xxx') + expect(options.cancel).toBeDefined() + + options = didInsertSpy.mostRecentCall.args[0] + expect(options.text).toBe('xxx') + }) + + it('cancels text insertion when an ::onWillInsertText observer calls cancel on an event', () => { + const willInsertSpy = jasmine.createSpy().andCallFake(({cancel}) => cancel()) + + const didInsertSpy = jasmine.createSpy() + + editor.onWillInsertText(willInsertSpy) + editor.onDidInsertText(didInsertSpy) + + expect(editor.insertText('xxx')).toBe(false) + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + expect(willInsertSpy).toHaveBeenCalled() + expect(didInsertSpy).not.toHaveBeenCalled() + }) + }) + + describe("when the undo option is set to 'skip'", () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 2], [1, 2]])) + + it('does not undo the skipped operation', () => { + let range = editor.insertText('x') + range = editor.insertText('y', {undo: 'skip'}) + editor.undo() + expect(buffer.lineForRow(1)).toBe(' yvar sort = function(items) {') + }) + }) + }) + + describe('.insertNewline()', () => { + describe('when there is a single cursor', () => { + describe('when the cursor is at the beginning of a line', () => { + it('inserts an empty line before it', () => { + editor.setCursorScreenPosition({row: 1, column: 0}) + + editor.insertNewline() + + expect(buffer.lineForRow(1)).toBe('') + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + + describe('when the cursor is in the middle of a line', () => { + it('splits the current line to form a new line', () => { + editor.setCursorScreenPosition({row: 1, column: 6}) + const originalLine = buffer.lineForRow(1) + const lineBelowOriginalLine = buffer.lineForRow(2) + + editor.insertNewline() + + expect(buffer.lineForRow(1)).toBe(originalLine.slice(0, 6)) + expect(buffer.lineForRow(2)).toBe(originalLine.slice(6)) + expect(buffer.lineForRow(3)).toBe(lineBelowOriginalLine) + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + + describe('when the cursor is on the end of a line', () => { + it('inserts an empty line after it', () => { + editor.setCursorScreenPosition({row: 1, column: buffer.lineForRow(1).length}) + + editor.insertNewline() + + expect(buffer.lineForRow(2)).toBe('') + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when the cursors are on the same line', () => { + it('breaks the line at the cursor locations', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.insertNewline() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot') + expect(editor.lineTextForBufferRow(4)).toBe(' = items.shift(), current') + expect(editor.lineTextForBufferRow(5)).toBe(', left = [], right = [];') + expect(editor.lineTextForBufferRow(6)).toBe(' while(items.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([4, 0]) + expect(cursor2.getBufferPosition()).toEqual([5, 0]) + }) + }) + + describe('when the cursors are on different lines', () => { + it('inserts newlines at each cursor location', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addCursorAtScreenPosition([6, 0]) + + editor.insertText('\n') + expect(editor.lineTextForBufferRow(3)).toBe('') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(6)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(7)).toBe('') + expect(editor.lineTextForBufferRow(8)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(9)).toBe(' }') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([4, 0]) + expect(cursor2.getBufferPosition()).toEqual([8, 0]) + }) + }) + }) + }) + + describe('.insertNewlineBelow()', () => { + describe('when the operation is undone', () => { + it('places the cursor back at the previous location', () => { + editor.setCursorBufferPosition([0, 2]) + editor.insertNewlineBelow() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.undo() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + }) + + it("inserts a newline below the cursor's current line, autoindents it, and moves the cursor to the end of the line", () => { + editor.update({autoIndent: true}) + editor.insertNewlineBelow() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' ') + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + }) + }) + + describe('.insertNewlineAbove()', () => { + describe('when the cursor is on first line', () => { + it('inserts a newline on the first line and moves the cursor to the first line', () => { + editor.setCursorBufferPosition([0]) + editor.insertNewlineAbove() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + expect(editor.lineTextForBufferRow(0)).toBe('') + expect(editor.lineTextForBufferRow(1)).toBe('var quicksort = function () {') + expect(editor.buffer.getLineCount()).toBe(14) + }) + }) + + describe('when the cursor is not on the first line', () => { + it('inserts a newline above the current line and moves the cursor to the inserted line', () => { + editor.setCursorBufferPosition([3, 4]) + editor.insertNewlineAbove() + expect(editor.getCursorBufferPosition()).toEqual([3, 0]) + expect(editor.lineTextForBufferRow(3)).toBe('') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.buffer.getLineCount()).toBe(14) + + editor.undo() + expect(editor.getCursorBufferPosition()).toEqual([3, 4]) + }) + }) + + it('indents the new line to the correct level when editor.autoIndent is true', () => { + editor.update({autoIndent: true}) + + editor.setText(' var test') + editor.setCursorBufferPosition([0, 2]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + expect(editor.lineTextForBufferRow(0)).toBe(' ') + expect(editor.lineTextForBufferRow(1)).toBe(' var test') + + editor.setText('\n var test') + editor.setCursorBufferPosition([1, 2]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + expect(editor.lineTextForBufferRow(0)).toBe('') + expect(editor.lineTextForBufferRow(1)).toBe(' ') + expect(editor.lineTextForBufferRow(2)).toBe(' var test') + + editor.setText('function() {\n}') + editor.setCursorBufferPosition([1, 1]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + expect(editor.lineTextForBufferRow(0)).toBe('function() {') + expect(editor.lineTextForBufferRow(1)).toBe(' ') + expect(editor.lineTextForBufferRow(2)).toBe('}') + }) + }) + + describe('.insertNewLine()', () => { + describe('when a new line is appended before a closing tag (e.g. by pressing enter before a selection)', () => { + it('moves the line down and keeps the indentation level the same when editor.autoIndent is true', () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([9, 2]) + editor.insertNewline() + expect(editor.lineTextForBufferRow(10)).toBe(' };') + }) + }) + + describe('when a newline is appended with a trailing closing tag behind the cursor (e.g. by pressing enter in the middel of a line)', () => { + it('indents the new line to the correct level when editor.autoIndent is true and using a curly-bracket language', () => { + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.js')) + editor.setText('var test = () => {\n return true;};') + editor.setCursorBufferPosition([1, 14]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + }) + + it('indents the new line to the current level when editor.autoIndent is true and no increaseIndentPattern is specified', () => { + editor.setGrammar(atom.grammars.selectGrammar('file')) + editor.update({autoIndent: true}) + editor.setText(' if true') + editor.setCursorBufferPosition([0, 8]) + editor.insertNewline() + expect(editor.getGrammar()).toBe(atom.grammars.nullGrammar) + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(1) + }) + + it('indents the new line to the correct level when editor.autoIndent is true and using an off-side rule language', async () => { + await atom.packages.activatePackage('language-coffee-script') + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.coffee')) + editor.setText('if true\n return trueelse\n return false') + editor.setCursorBufferPosition([1, 13]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + expect(editor.indentationForBufferRow(3)).toBe(1) + }) + }) + + describe('when a newline is appended on a line that matches the decreaseNextIndentPattern', () => { + it('indents the new line to the correct level when editor.autoIndent is true', async () => { + await atom.packages.activatePackage('language-go') + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.go')) + editor.setText('fmt.Printf("some%s",\n "thing")') + editor.setCursorBufferPosition([1, 10]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + }) + }) + }) + + describe('.backspace()', () => { + describe('when there is a single cursor', () => { + let changeScreenRangeHandler = null + + beforeEach(() => { + const selection = editor.getLastSelection() + changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') + selection.onDidChangeRange(changeScreenRangeHandler) + }) + + describe('when the cursor is on the middle of the line', () => { + it('removes the character before the cursor', () => { + editor.setCursorScreenPosition({row: 1, column: 7}) + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + editor.backspace() + + const line = buffer.lineForRow(1) + expect(line).toBe(' var ort = function(items) {') + expect(editor.getCursorScreenPosition()).toEqual({row: 1, column: 6}) + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the cursor is at the beginning of a line', () => { + it('joins it with the line above', () => { + const originalLine0 = buffer.lineForRow(0) + expect(originalLine0).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + editor.setCursorScreenPosition({row: 1, column: 0}) + editor.backspace() + + const line0 = buffer.lineForRow(0) + const line1 = buffer.lineForRow(1) + expect(line0).toBe('var quicksort = function () { var sort = function(items) {') + expect(line1).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorScreenPosition()).toEqual([0, originalLine0.length]) + + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the cursor is at the first column of the first line', () => { + it("does nothing, but doesn't raise an error", () => { + editor.setCursorScreenPosition({row: 0, column: 0}) + editor.backspace() + }) + }) + + describe('when the cursor is after a fold', () => { + it('deletes the folded range', () => { + editor.foldBufferRange([[4, 7], [5, 8]]) + editor.setCursorBufferPosition([5, 8]) + editor.backspace() + + expect(buffer.lineForRow(4)).toBe(' whirrent = items.shift();') + expect(editor.isFoldedAtBufferRow(4)).toBe(false) + }) + }) + + describe('when the cursor is in the middle of a line below a fold', () => { + it('backspaces as normal', () => { + editor.setCursorScreenPosition([4, 0]) + editor.foldCurrentRow() + editor.setCursorScreenPosition([5, 5]) + editor.backspace() + + expect(buffer.lineForRow(7)).toBe(' }') + expect(buffer.lineForRow(8)).toBe(' eturn sort(left).concat(pivot).concat(sort(right));') + }) + }) + + describe('when the cursor is on a folded screen line', () => { + it('deletes the contents of the fold before the cursor', () => { + editor.setCursorBufferPosition([3, 0]) + editor.foldCurrentRow() + editor.backspace() + + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getCursorScreenPosition()).toEqual([1, 29]) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when cursors are on the same line', () => { + it('removes the characters preceding each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.backspace() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivo = items.shift(), curren, left = [], right = [];') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 12]) + expect(cursor2.getBufferPosition()).toEqual([3, 36]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + }) + + describe('when cursors are on different lines', () => { + describe('when the cursors are in the middle of their lines', () => + it('removes the characters preceding each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([4, 10]) + + editor.backspace() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivo = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' whileitems.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 12]) + expect(cursor2.getBufferPosition()).toEqual([4, 9]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + ) + + describe('when the cursors are on the first column of their lines', () => + it('removes the newlines preceding each cursor', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addCursorAtScreenPosition([6, 0]) + + editor.backspace() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items; var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' current = items.shift(); current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(5)).toBe(' }') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([2, 40]) + expect(cursor2.getBufferPosition()).toEqual([4, 30]) + }) + ) + }) + }) + + describe('when there is a single selection', () => { + it('deletes the selection, but not the character before it', () => { + editor.setSelectedBufferRange([[0, 5], [0, 9]]) + editor.backspace() + expect(editor.buffer.lineForRow(0)).toBe('var qsort = function () {') + }) + + describe('when the selection ends on a folded line', () => { + it('preserves the fold', () => { + editor.setSelectedBufferRange([[3, 0], [4, 0]]) + editor.foldBufferRow(4) + editor.backspace() + + expect(buffer.lineForRow(3)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtScreenRow(3)).toBe(true) + }) + }) + }) + + describe('when there are multiple selections', () => { + it('removes all selected text', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + editor.backspace() + expect(editor.lineTextForBufferRow(0)).toBe('var = () {') + }) + }) + }) + + describe('.deleteToPreviousWordBoundary()', () => { + describe('when no text is selected', () => { + it('deletes to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 16]) + editor.addCursorAtBufferPosition([1, 21]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = (items) {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 13]) + + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort function () {') + expect(buffer.lineForRow(1)).toBe(' var sort =(items) {') + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([1, 12]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.deleteToNextWordBoundary()', () => { + describe('when no text is selected', () => { + it('deletes to the next word boundary', () => { + editor.setCursorBufferPosition([0, 15]) + editor.addCursorAtBufferPosition([1, 24]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort = () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =() {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it{') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.deleteToBeginningOfWord()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the beginning of the word', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([3, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(ems) {') + expect(buffer.lineForRow(3)).toBe(' ar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 22]) + expect(cursor2.getBufferPosition()).toEqual([3, 4]) + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = functionems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return itemsar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 21]) + expect(cursor2.getBufferPosition()).toEqual([2, 39]) + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = ems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return ar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 13]) + expect(cursor2.getBufferPosition()).toEqual([2, 34]) + + editor.setText(' var sort') + editor.setCursorBufferPosition([0, 2]) + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(0)).toBe('var sort') + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + }) + }) + }) + + describe('.deleteToEndOfLine()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the end of the line', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it') + expect(buffer.lineForRow(2)).toBe(' i') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + }) + + describe('when at the end of the line', () => { + it('deletes the next newline', () => { + editor.setCursorBufferPosition([1, 30]) + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + }) + + describe('when text is selected', () => { + it('deletes only the text in the selection', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + }) + }) + }) + + describe('.deleteToBeginningOfLine()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the beginning of the line', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe('ems) {') + expect(buffer.lineForRow(2)).toBe('f (items.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 0]) + expect(cursor2.getBufferPosition()).toEqual([2, 0]) + }) + + describe('when at the beginning of the line', () => { + it('deletes the newline', () => { + editor.setCursorBufferPosition([2]) + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + }) + + describe('when text is selected', () => { + it('still deletes all text to beginning of the line', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe('ems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + }) + }) + }) + + describe('.delete()', () => { + describe('when there is a single cursor', () => { + describe('when the cursor is on the middle of a line', () => { + it('deletes the character following the cursor', () => { + editor.setCursorScreenPosition([1, 6]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var ort = function(items) {') + }) + }) + + describe('when the cursor is on the end of a line', () => { + it('joins the line with the following line', () => { + editor.setCursorScreenPosition([1, buffer.lineForRow(1).length]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + + describe('when the cursor is on the last column of the last line', () => { + it("does nothing, but doesn't raise an error", () => { + editor.setCursorScreenPosition([12, buffer.lineForRow(12).length]) + editor.delete() + expect(buffer.lineForRow(12)).toBe('};') + }) + }) + + describe('when the cursor is before a fold', () => { + it('only deletes the lines inside the fold', () => { + editor.foldBufferRange([[3, 6], [4, 8]]) + editor.setCursorScreenPosition([3, 6]) + const cursorPositionBefore = editor.getCursorScreenPosition() + + editor.delete() + + expect(buffer.lineForRow(3)).toBe(' vae(items.length > 0) {') + expect(buffer.lineForRow(4)).toBe(' current = items.shift();') + expect(editor.getCursorScreenPosition()).toEqual(cursorPositionBefore) + }) + }) + + describe('when the cursor is in the middle a line above a fold', () => { + it('deletes as normal', () => { + editor.foldBufferRow(4) + editor.setCursorScreenPosition([3, 4]) + const cursorPositionBefore = editor.getCursorScreenPosition() + + editor.delete() + + expect(buffer.lineForRow(3)).toBe(' ar pivot = items.shift(), current, left = [], right = [];') + expect(editor.isFoldedAtScreenRow(4)).toBe(true) + expect(editor.getCursorScreenPosition()).toEqual([3, 4]) + }) + }) + + describe('when the cursor is inside a fold', () => { + it('removes the folded content after the cursor', () => { + editor.foldBufferRange([[2, 6], [6, 21]]) + editor.setCursorBufferPosition([4, 9]) + + editor.delete() + + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(buffer.lineForRow(4)).toBe(' while ? left.push(current) : right.push(current);') + expect(buffer.lineForRow(5)).toBe(' }') + expect(editor.getCursorBufferPosition()).toEqual([4, 9]) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when cursors are on the same line', () => { + it('removes the characters following each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.delete() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot= items.shift(), current left = [], right = [];') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 37]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + }) + + describe('when cursors are on different lines', () => { + describe('when the cursors are in the middle of the lines', () => + it('removes the characters following each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([4, 10]) + + editor.delete() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot= items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(tems.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 13]) + expect(cursor2.getBufferPosition()).toEqual([4, 10]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + ) + + describe('when the cursors are at the end of their lines', () => + it('removes the newlines following each cursor', () => { + editor.setCursorScreenPosition([0, 29]) + editor.addCursorAtScreenPosition([1, 30]) + + editor.delete() + + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) { if (items.length <= 1) return items;') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 29]) + expect(cursor2.getBufferPosition()).toEqual([0, 59]) + }) + ) + }) + }) + + describe('when there is a single selection', () => { + it('deletes the selection, but not the character following it', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + expect(editor.getLastSelection().isEmpty()).toBeTruthy() + }) + }) + + describe('when there are multiple selections', () => + describe('when selections are on the same line', () => { + it('removes all selected text', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + editor.delete() + expect(editor.lineTextForBufferRow(0)).toBe('var = () {') + }) + }) + ) + }) + + describe('.deleteToEndOfWord()', () => { + describe('when no text is selected', () => { + it('deletes to the end of the word', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe(' i (items.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it {') + expect(buffer.lineForRow(2)).toBe(' iitems.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.indent()', () => { + describe('when the selection is empty', () => { + describe('when autoIndent is disabled', () => { + describe("if 'softTabs' is true (the default)", () => { + it("inserts 'tabLength' spaces into the buffer", () => { + const tabRegex = new RegExp(`^[ ]{${editor.getTabLength()}}`) + expect(buffer.lineForRow(0)).not.toMatch(tabRegex) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(tabRegex) + }) + + it('respects the tab stops when cursor is in the middle of a tab', () => { + editor.setTabLength(4) + buffer.insert([12, 2], '\n ') + editor.setCursorBufferPosition([13, 1]) + editor.indent() + expect(buffer.lineForRow(13)).toMatch(/^\s+$/) + expect(buffer.lineForRow(13).length).toBe(4) + expect(editor.getCursorBufferPosition()).toEqual([13, 4]) + + buffer.insert([13, 0], ' ') + editor.setCursorBufferPosition([13, 6]) + editor.indent() + expect(buffer.lineForRow(13).length).toBe(8) + }) + }) + + describe("if 'softTabs' is false", () => + it('insert a \t into the buffer', () => { + editor.setSoftTabs(false) + expect(buffer.lineForRow(0)).not.toMatch(/^\t/) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t/) + }) + ) + }) + + describe('when autoIndent is enabled', () => { + describe("when the cursor's column is less than the suggested level of indentation", () => { + describe("when 'softTabs' is true (the default)", () => { + it('moves the cursor to the end of the leading whitespace and inserts enough whitespace to bring the line to the suggested level of indentation', () => { + buffer.insert([5, 0], ' \n') + editor.setCursorBufferPosition([5, 0]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(5)).toMatch(/^\s+$/) + expect(buffer.lineForRow(5).length).toBe(6) + expect(editor.getCursorBufferPosition()).toEqual([5, 6]) + }) + + it('respects the tab stops when cursor is in the middle of a tab', () => { + editor.setTabLength(4) + buffer.insert([12, 2], '\n ') + editor.setCursorBufferPosition([13, 1]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(13)).toMatch(/^\s+$/) + expect(buffer.lineForRow(13).length).toBe(4) + expect(editor.getCursorBufferPosition()).toEqual([13, 4]) + + buffer.insert([13, 0], ' ') + editor.setCursorBufferPosition([13, 6]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(13).length).toBe(8) + }) + }) + + describe("when 'softTabs' is false", () => { + it('moves the cursor to the end of the leading whitespace and inserts enough tabs to bring the line to the suggested level of indentation', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + buffer.insert([5, 0], '\t\n') + editor.setCursorBufferPosition([5, 0]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(5)).toMatch(/^\t\t\t$/) + expect(editor.getCursorBufferPosition()).toEqual([5, 3]) + }) + + describe('when the difference between the suggested level of indentation and the current level of indentation is greater than 0 but less than 1', () => + it('inserts one tab', () => { + editor.setSoftTabs(false) + buffer.setText(' \ntest') + editor.setCursorBufferPosition([1, 0]) + + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(1)).toBe('\ttest') + expect(editor.getCursorBufferPosition()).toEqual([1, 1]) + }) + ) + }) + }) + + describe("when the line's indent level is greater than the suggested level of indentation", () => { + describe("when 'softTabs' is true (the default)", () => + it("moves the cursor to the end of the leading whitespace and inserts 'tabLength' spaces into the buffer", () => { + buffer.insert([7, 0], ' \n') + editor.setCursorBufferPosition([7, 2]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(7)).toMatch(/^\s+$/) + expect(buffer.lineForRow(7).length).toBe(8) + expect(editor.getCursorBufferPosition()).toEqual([7, 8]) + }) + ) + + describe("when 'softTabs' is false", () => + it('moves the cursor to the end of the leading whitespace and inserts \t into the buffer', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + buffer.insert([7, 0], '\t\t\t\n') + editor.setCursorBufferPosition([7, 1]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(7)).toMatch(/^\t\t\t\t$/) + expect(editor.getCursorBufferPosition()).toEqual([7, 4]) + }) + ) + }) + }) + }) + + describe('when the selection is not empty', () => { + it('indents the selected lines', () => { + editor.setSelectedBufferRange([[0, 0], [10, 0]]) + const selection = editor.getLastSelection() + spyOn(selection, 'indentSelectedRows') + editor.indent() + expect(selection.indentSelectedRows).toHaveBeenCalled() + }) + }) + + describe('if editor.softTabs is false', () => { + it('inserts a tab character into the buffer', () => { + editor.setSoftTabs(false) + expect(buffer.lineForRow(0)).not.toMatch(/^\t/) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t/) + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.getTabLength()]) + + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t\t/) + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.getTabLength() * 2]) + }) + }) + }) + + describe('clipboard operations', () => { + describe('.cutSelectedText()', () => { + it('removes the selected text from the buffer and places it on the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + editor.cutSelectedText() + expect(buffer.lineForRow(0)).toBe('var = function () {') + expect(buffer.lineForRow(1)).toBe(' var = function(items) {') + expect(clipboard.readText()).toBe('quicksort\nsort') + }) + + describe('when no text is selected', () => { + beforeEach(() => + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[5, 0], [5, 0]] + ]) + ) + + it('cuts the lines on which there are cursors', () => { + editor.cutSelectedText() + expect(buffer.getLineCount()).toBe(11) + expect(buffer.lineForRow(1)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(4)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(atom.clipboard.read()).toEqual([ + 'var quicksort = function () {', + '', + ' current = items.shift();', + '' + ].join('\n')) + }) + }) + + describe('when many selections get added in shuffle order', () => { + it('cuts them in order', () => { + editor.setSelectedBufferRanges([ + [[2, 8], [2, 13]], + [[0, 4], [0, 13]], + [[1, 6], [1, 10]] + ]) + editor.cutSelectedText() + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + }) + + describe('.cutToEndOfLine()', () => { + describe('when soft wrap is on', () => { + it('cuts up to the end of the line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(25) + editor.setCursorScreenPosition([2, 6]) + editor.cutToEndOfLine() + expect(editor.lineTextForScreenRow(2)).toBe(' var function(items) {') + }) + }) + + describe('when soft wrap is off', () => { + describe('when nothing is selected', () => + it('cuts up to the end of the line', () => { + editor.setCursorBufferPosition([2, 20]) + editor.addCursorAtBufferPosition([3, 20]) + editor.cutToEndOfLine() + expect(buffer.lineForRow(2)).toBe(' if (items.length') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) return items;\ns.shift(), current, left = [], right = [];') + }) + ) + + describe('when text is selected', () => + it('only cuts the selected text, not to the end of the line', () => { + editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) + editor.cutToEndOfLine() + expect(buffer.lineForRow(2)).toBe(' if (items.lengthurn items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) ret\ns.shift(), current, left = [], right = [];') + }) + ) + }) + }) + + describe('.cutToEndOfBufferLine()', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + }) + + describe('when nothing is selected', () => { + it('cuts up to the end of the buffer line', () => { + editor.setCursorBufferPosition([2, 20]) + editor.addCursorAtBufferPosition([3, 20]) + editor.cutToEndOfBufferLine() + expect(buffer.lineForRow(2)).toBe(' if (items.length') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) return items;\ns.shift(), current, left = [], right = [];') + }) + }) + + describe('when text is selected', () => { + it('only cuts the selected text, not to the end of the buffer line', () => { + editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) + editor.cutToEndOfBufferLine() + expect(buffer.lineForRow(2)).toBe(' if (items.lengthurn items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) ret\ns.shift(), current, left = [], right = [];') + }) + }) + }) + + describe('.copySelectedText()', () => { + it('copies selected text onto the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) + editor.copySelectedText() + + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(clipboard.readText()).toBe('quicksort\nsort\nitems') + expect(atom.clipboard.read()).toEqual('quicksort\nsort\nitems') + }) + + describe('when no text is selected', () => { + beforeEach(() => { + editor.setSelectedBufferRanges([ + [[1, 5], [1, 5]], + [[5, 8], [5, 8]] + ]) + }) + + it('copies the lines on which there are cursors', () => { + editor.copySelectedText() + expect(atom.clipboard.read()).toEqual([ + ' var sort = function(items) {\n', + ' current = items.shift();\n' + ].join('\n')) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 5], [1, 5]], + [[5, 8], [5, 8]] + ]) + }) + }) + + describe('when many selections get added in shuffle order', () => { + it('copies them in order', () => { + editor.setSelectedBufferRanges([ + [[2, 8], [2, 13]], + [[0, 4], [0, 13]], + [[1, 6], [1, 10]] + ]) + editor.copySelectedText() + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + }) + + describe('.copyOnlySelectedText()', () => { + describe('when thee are multiple selections', () => { + it('copies selected text onto the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) + + editor.copyOnlySelectedText() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(clipboard.readText()).toBe('quicksort\nsort\nitems') + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + + describe('when no text is selected', () => { + it('does not copy anything', () => { + editor.setCursorBufferPosition([1, 5]) + editor.copyOnlySelectedText() + expect(atom.clipboard.read()).toEqual('initial clipboard content') + }) + }) + }) + + describe('.pasteText()', () => { + const copyText = function (text, {startColumn, textEditor} = {}) { + if (startColumn == null) startColumn = 0 + if (textEditor == null) textEditor = editor + textEditor.setCursorBufferPosition([0, 0]) + textEditor.insertText(text) + const numberOfNewlines = text.match(/\n/g).length + const endColumn = text.match(/[^\n]*$/)[0].length + textEditor.getLastSelection().setBufferRange([[0, startColumn], [numberOfNewlines, endColumn]]) + return textEditor.cutSelectedText() + } + + it('pastes text into the buffer', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + atom.clipboard.write('first') + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var first = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var first = function(items) {') + }) + + it('notifies ::onWillInsertText observers', () => { + const insertedStrings = [] + editor.onWillInsertText(function ({text, cancel}) { + insertedStrings.push(text) + cancel() + }) + + atom.clipboard.write('hello') + editor.pasteText() + + expect(insertedStrings).toEqual(['hello']) + }) + + it('notifies ::onDidInsertText observers', () => { + const insertedStrings = [] + editor.onDidInsertText(({text, range}) => insertedStrings.push(text)) + + atom.clipboard.write('hello') + editor.pasteText() + + expect(insertedStrings).toEqual(['hello']) + }) + + describe('when `autoIndentOnPaste` is true', () => { + beforeEach(() => editor.update({autoIndentOnPaste: true})) + + describe('when pasting multiple lines before any non-whitespace characters', () => { + it('auto-indents the lines spanned by the pasted text, based on the first pasted line', () => { + atom.clipboard.write('a(x);\n b(x);\n c(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + // Adjust the indentation of the pasted lines while preserving + // their indentation relative to each other. Also preserve the + // indentation of the following line. + expect(editor.lineTextForBufferRow(5)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(6)).toBe(' b(x);') + expect(editor.lineTextForBufferRow(7)).toBe(' c(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' current = items.shift();') + }) + + it('auto-indents lines with a mix of hard tabs and spaces without removing spaces', () => { + editor.setSoftTabs(false) + expect(editor.indentationForBufferRow(5)).toBe(3) + + atom.clipboard.write('/**\n\t * testing\n\t * indent\n\t **/\n', {indentBasis: 1}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + // Do not lose the alignment spaces + expect(editor.lineTextForBufferRow(5)).toBe('\t\t\t/**') + expect(editor.lineTextForBufferRow(6)).toBe('\t\t\t * testing') + expect(editor.lineTextForBufferRow(7)).toBe('\t\t\t * indent') + expect(editor.lineTextForBufferRow(8)).toBe('\t\t\t **/') + }) + }) + + describe('when pasting line(s) above a line that matches the decreaseIndentPattern', () => + it('auto-indents based on the pasted line(s) only', () => { + atom.clipboard.write('a(x);\n b(x);\n c(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([7, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(7)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' b(x);') + expect(editor.lineTextForBufferRow(9)).toBe(' c(x);') + expect(editor.lineTextForBufferRow(10)).toBe(' }') + }) + ) + + describe('when pasting a line of text without line ending', () => + it('does not auto-indent the text', () => { + atom.clipboard.write('a(x);', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(5)).toBe('a(x); current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' current < pivot ? left.push(current) : right.push(current);') + }) + ) + + describe('when pasting on a line after non-whitespace characters', () => + it('does not auto-indent the affected line', () => { + // Before the paste, the indentation is non-standard. + editor.setText(dedent`\ + if (x) { + y(); + }\ + `) + + atom.clipboard.write(' z();\n h();') + editor.setCursorBufferPosition([1, Infinity]) + + // The indentation of the non-standard line is unchanged. + editor.pasteText() + expect(editor.lineTextForBufferRow(1)).toBe(' y(); z();') + expect(editor.lineTextForBufferRow(2)).toBe(' h();') + }) + ) + }) + + describe('when `autoIndentOnPaste` is false', () => { + beforeEach(() => editor.update({autoIndentOnPaste: false})) + + describe('when the cursor is indented further than the original copied text', () => + it('increases the indentation of the copied lines to match', () => { + editor.setSelectedBufferRange([[1, 2], [3, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([5, 6]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(5)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is indented less far than the original copied text', () => + it('decreases the indentation of the copied lines to match', () => { + editor.setSelectedBufferRange([[6, 6], [8, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([1, 2]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(1)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(2)).toBe('}') + }) + ) + + describe('when the first copied line has leading whitespace', () => + it("preserves the line's leading whitespace", () => { + editor.setSelectedBufferRange([[4, 0], [6, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([0, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(0)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(1)).toBe(' current = items.shift();') + }) + ) + }) + + describe('when the clipboard has many selections', () => { + beforeEach(() => { + editor.update({autoIndentOnPaste: false}) + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + editor.copySelectedText() + }) + + it('pastes each selection in order separately into the buffer', () => { + editor.setSelectedBufferRanges([ + [[1, 6], [1, 10]], + [[0, 4], [0, 13]] + ]) + + editor.moveRight() + editor.insertText('_') + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort_quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var sort_sort = function(items) {') + }) + + describe('and the selections count does not match', () => { + beforeEach(() => editor.setSelectedBufferRanges([[[0, 4], [0, 13]]])) + + it('pastes the whole text into the buffer', () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort') + expect(editor.lineTextForBufferRow(1)).toBe('sort = function () {') + }) + }) + }) + + describe('when a full line was cut', () => { + beforeEach(() => { + editor.setCursorBufferPosition([2, 13]) + editor.cutSelectedText() + editor.setCursorBufferPosition([2, 13]) + }) + + it("pastes the line above the cursor and retains the cursor's column", () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getCursorBufferPosition()).toEqual([3, 13]) + }) + }) + + describe('when a full line was copied', () => { + beforeEach(() => { + editor.setCursorBufferPosition([2, 13]) + editor.copySelectedText() + }) + + describe('when there is a selection', () => + it('overwrites the selection as with any copied text', () => { + editor.setSelectedBufferRange([[1, 2], [1, Infinity]]) + editor.pasteText() + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe('') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorBufferPosition()).toEqual([2, 0]) + }) + ) + + describe('when there is no selection', () => + it("pastes the line above the cursor and retains the cursor's column", () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorBufferPosition()).toEqual([3, 13]) + }) + ) + }) + + it('respects options that preserve the formatting of the pasted text', () => { + editor.update({autoIndentOnPaste: true}) + atom.clipboard.write('a(x);\n b(x);\r\nc(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.insertText(' ') + editor.pasteText({autoIndent: false, preserveTrailingLineIndentation: true, normalizeLineEndings: false}) + + expect(editor.lineTextForBufferRow(5)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(6)).toBe(' b(x);') + expect(editor.buffer.lineEndingForRow(6)).toBe('\r\n') + expect(editor.lineTextForBufferRow(7)).toBe('c(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' current = items.shift();') + }) + }) + }) + + describe('.indentSelectedRows()', () => { + describe('when nothing is selected', () => { + describe('when softTabs is enabled', () => { + it('indents line and retains selection', () => { + editor.setSelectedBufferRange([[0, 3], [0, 3]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3 + editor.getTabLength()], [0, 3 + editor.getTabLength()]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents line and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[0, 3], [0, 3]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3 + 1], [0, 3 + 1]]) + }) + }) + }) + + describe('when one line is selected', () => { + describe('when softTabs is enabled', () => { + it('indents line and retains selection', () => { + editor.setSelectedBufferRange([[0, 4], [0, 14]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe(`${editor.getTabText()}var quicksort = function () {`) + expect(editor.getSelectedBufferRange()).toEqual([[0, 4 + editor.getTabLength()], [0, 14 + editor.getTabLength()]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents line and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[0, 4], [0, 14]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 4 + 1], [0, 14 + 1]]) + }) + }) + }) + + describe('when multiple lines are selected', () => { + describe('when softTabs is enabled', () => { + it('indents selected lines (that are not empty) and retains selection', () => { + editor.setSelectedBufferRange([[9, 1], [11, 15]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe(' };') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + editor.getTabLength()], [11, 15 + editor.getTabLength()]]) + }) + + it('does not indent the last row if the selection ends at column 0', () => { + editor.setSelectedBufferRange([[9, 1], [11, 0]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe(' };') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + editor.getTabLength()], [11, 0]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents selected lines (that are not empty) and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[9, 1], [11, 15]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe('\t\t};') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe('\t\treturn sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + 1], [11, 15 + 1]]) + }) + }) + }) + }) + + describe('.outdentSelectedRows()', () => { + describe('when nothing is selected', () => { + it('outdents line and retains selection', () => { + editor.setSelectedBufferRange([[1, 3], [1, 3]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[1, 3 - editor.getTabLength()], [1, 3 - editor.getTabLength()]]) + }) + + it('outdents when indent is less than a tab length', () => { + editor.insertText(' ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents a single hard tab when indent is multiple hard tabs and and the session is using soft tabs', () => { + editor.insertText('\t\t') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents when a mix of hard tabs and soft tabs are used', () => { + editor.insertText('\t ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents only up to the first non-space non-tab character', () => { + editor.insertText(' \tfoo\t ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tfoo\t var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('foo\t var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('foo\t var quicksort = function () {') + }) + }) + + describe('when one line is selected', () => { + it('outdents line and retains editor', () => { + editor.setSelectedBufferRange([[1, 4], [1, 14]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[1, 4 - editor.getTabLength()], [1, 14 - editor.getTabLength()]]) + }) + }) + + describe('when multiple lines are selected', () => { + it('outdents selected lines and retains editor', () => { + editor.setSelectedBufferRange([[0, 1], [3, 15]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 15 - editor.getTabLength()]]) + }) + + it('does not outdent the last line of the selection if it ends at column 0', () => { + editor.setSelectedBufferRange([[0, 1], [3, 0]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 0]]) + }) + }) + }) + + describe('.autoIndentSelectedRows', () => { + it('auto-indents the selection', () => { + editor.setCursorBufferPosition([2, 0]) + editor.insertText('function() {\ninside=true\n}\n i=1\n') + editor.getLastSelection().setBufferRange([[2, 0], [6, 0]]) + editor.autoIndentSelectedRows() + + expect(editor.lineTextForBufferRow(2)).toBe(' function() {') + expect(editor.lineTextForBufferRow(3)).toBe(' inside=true') + expect(editor.lineTextForBufferRow(4)).toBe(' }') + expect(editor.lineTextForBufferRow(5)).toBe(' i=1') + }) + }) + + describe('.undo() and .redo()', () => { + it('undoes/redoes the last change', () => { + editor.insertText('foo') + editor.undo() + expect(buffer.lineForRow(0)).not.toContain('foo') + + editor.redo() + expect(buffer.lineForRow(0)).toContain('foo') + }) + + it('batches the undo / redo of changes caused by multiple cursors', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + + editor.insertText('foo') + editor.backspace() + + expect(buffer.lineForRow(0)).toContain('fovar') + expect(buffer.lineForRow(1)).toContain('fo ') + + editor.undo() + + expect(buffer.lineForRow(0)).toContain('foo') + expect(buffer.lineForRow(1)).toContain('foo') + + editor.redo() + + expect(buffer.lineForRow(0)).not.toContain('foo') + expect(buffer.lineForRow(0)).toContain('fovar') + }) + + it('restores cursors and selections to their states before and after undone and redone changes', () => { + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[1, 0], [1, 3]] + ]) + editor.insertText('abc') + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 3]], + [[1, 3], [1, 3]] + ]) + + editor.setCursorBufferPosition([0, 0]) + editor.setSelectedBufferRanges([ + [[2, 0], [2, 0]], + [[3, 0], [3, 0]], + [[4, 0], [4, 3]] + ]) + editor.insertText('def') + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 3], [2, 3]], + [[3, 3], [3, 3]], + [[4, 3], [4, 3]] + ]) + + editor.setCursorBufferPosition([0, 0]) + editor.undo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 0], [2, 0]], + [[3, 0], [3, 0]], + [[4, 0], [4, 3]] + ]) + + editor.undo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 0], [1, 3]] + ]) + + editor.redo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 3]], + [[1, 3], [1, 3]] + ]) + + editor.redo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 3], [2, 3]], + [[3, 3], [3, 3]], + [[4, 3], [4, 3]] + ]) + }) + + it('restores the selected ranges after undo and redo', () => { + editor.setSelectedBufferRanges([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) + editor.delete() + editor.delete() + + const selections = editor.getSelections() + expect(buffer.lineForRow(1)).toBe(' var = function( {') + + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 17], [1, 17]]]) + + editor.undo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 18], [1, 18]]]) + + editor.undo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) + + editor.redo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 18], [1, 18]]]) + }) + + xit('restores folds after undo and redo', () => { + editor.foldBufferRow(1) + editor.setSelectedBufferRange([[1, 0], [10, Infinity]], {preserveFolds: true}) + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + + editor.insertText(dedent`\ + // testing + function foo() { + return 1 + 2; + }\ + `) + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + editor.foldBufferRow(2) + + editor.undo() + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + + editor.redo() + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + }) + }) + + describe('::transact', () => { + it('restores the selection when the transaction is undone/redone', () => { + buffer.setText('1234') + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + + editor.transact(() => { + editor.delete() + editor.moveToEndOfLine() + editor.insertText('5') + expect(buffer.getText()).toBe('145') + }) + + editor.undo() + expect(buffer.getText()).toBe('1234') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [0, 3]]) + + editor.redo() + expect(buffer.getText()).toBe('145') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3], [0, 3]]) + }) + }) + + describe('when the buffer is changed (via its direct api, rather than via than edit session)', () => { + it('moves the cursor so it is in the same relative position of the buffer', () => { + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + editor.addCursorAtScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + buffer.insert([0, 1], 'abc') + + expect(cursor1.getScreenPosition()).toEqual([0, 0]) + expect(cursor2.getScreenPosition()).toEqual([0, 8]) + expect(cursor3.getScreenPosition()).toEqual([1, 0]) + }) + + it('does not destroy cursors or selections when a change encompasses them', () => { + const cursor = editor.getLastCursor() + cursor.setBufferPosition([3, 3]) + editor.buffer.delete([[3, 1], [3, 5]]) + expect(cursor.getBufferPosition()).toEqual([3, 1]) + expect(editor.getCursors().indexOf(cursor)).not.toBe(-1) + + const selection = editor.getLastSelection() + selection.setBufferRange([[3, 5], [3, 10]]) + editor.buffer.delete([[3, 3], [3, 8]]) + expect(selection.getBufferRange()).toEqual([[3, 3], [3, 5]]) + expect(editor.getSelections().indexOf(selection)).not.toBe(-1) + }) + + it('merges cursors when the change causes them to overlap', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 2]) + editor.addCursorAtScreenPosition([1, 2]) + + const [cursor1, cursor2, cursor3] = editor.getCursors() + expect(editor.getCursors().length).toBe(3) + + buffer.delete([[0, 0], [0, 2]]) + + expect(editor.getCursors().length).toBe(2) + expect(editor.getCursors()).toEqual([cursor1, cursor3]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor3.getBufferPosition()).toEqual([1, 2]) + }) + }) + + describe('.moveSelectionLeft()', () => { + it('moves one active selection on one line one column to the left', () => { + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + expect(editor.getSelectedText()).toBe('quicksort') + + editor.moveSelectionLeft() + + expect(editor.getSelectedText()).toBe('quicksort') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3], [0, 12]]) + }) + + it('moves multiple active selections on one line one column to the left', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 3], [0, 12]], [[0, 15], [0, 23]]]) + }) + + it('moves multiple active selections on multiple lines one column to the left', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 3], [0, 12]], [[1, 5], [1, 9]]]) + }) + + describe('when a selection is at the first column of a line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe(' v') + + editor.moveSelectionLeft() + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe(' v') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) + }) + + describe('when multiple selections are active on one line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe('quicksort') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe('quicksort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) + }) + }) + }) + }) + + describe('.moveSelectionRight()', () => { + it('moves one active selection on one line one column to the right', () => { + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + expect(editor.getSelectedText()).toBe('quicksort') + + editor.moveSelectionRight() + + expect(editor.getSelectedText()).toBe('quicksort') + expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 14]]) + }) + + it('moves multiple active selections on one line one column to the right', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 5], [0, 14]], [[0, 17], [0, 25]]]) + }) + + it('moves multiple active selections on multiple lines one column to the right', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 5], [0, 14]], [[1, 7], [1, 11]]]) + }) + + describe('when a selection is at the last column of a line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('items;') + expect(selections[1].getText()).toBe('shift();') + + editor.moveSelectionRight() + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('items;') + expect(selections[1].getText()).toBe('shift();') + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) + }) + + describe('when multiple selections are active on one line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('return') + expect(selections[1].getText()).toBe('items;') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('return') + expect(selections[1].getText()).toBe('items;') + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) + }) + }) + }) + }) + }) + + describe('reading text', () => { + it('.lineTextForScreenRow(row)', () => { + editor.foldBufferRow(4) + expect(editor.lineTextForScreenRow(5)).toEqual(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForScreenRow(9)).toEqual('};') + expect(editor.lineTextForScreenRow(10)).toBeUndefined() + }) + }) + + describe('.deleteLine()', () => { + it('deletes the first line when the cursor is there', () => { + editor.getLastCursor().moveToTop() + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line1) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('deletes the last line when the cursor is there', () => { + const count = buffer.getLineCount() + const secondToLastLine = buffer.lineForRow(count - 2) + expect(buffer.lineForRow(count - 1)).not.toBe(secondToLastLine) + editor.getLastCursor().moveToBottom() + editor.deleteLine() + const newCount = buffer.getLineCount() + expect(buffer.lineForRow(newCount - 1)).toBe(secondToLastLine) + expect(newCount).toBe(count - 1) + }) + + it('deletes whole lines when partial lines are selected', () => { + editor.setSelectedBufferRange([[0, 2], [1, 2]]) + const line2 = buffer.lineForRow(2) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line2) + expect(buffer.lineForRow(1)).not.toBe(line2) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line2) + expect(buffer.getLineCount()).toBe(count - 2) + }) + + it('deletes a line only once when multiple selections are on the same line', () => { + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + editor.setSelectedBufferRanges([ + [[0, 1], [0, 2]], + [[0, 4], [0, 5]] + ]) + expect(buffer.lineForRow(0)).not.toBe(line1) + + editor.deleteLine() + + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('only deletes first line if only newline is selected on second line', () => { + editor.setSelectedBufferRange([[0, 2], [1, 0]]) + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line1) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('deletes the entire region when invoke on a folded region', () => { + editor.foldBufferRow(1) + editor.getLastCursor().moveToTop() + editor.getLastCursor().moveDown() + expect(buffer.getLineCount()).toBe(13) + editor.deleteLine() + expect(buffer.getLineCount()).toBe(4) + }) + + it('deletes the entire file from the bottom up', () => { + const count = buffer.getLineCount() + expect(count).toBeGreaterThan(0) + for (let i = 0; i < count; i++) { + editor.getLastCursor().moveToBottom() + editor.deleteLine() + } + expect(buffer.getLineCount()).toBe(1) + expect(buffer.getText()).toBe('') + }) + + it('deletes the entire file from the top down', () => { + const count = buffer.getLineCount() + expect(count).toBeGreaterThan(0) + for (let i = 0; i < count; i++) { + editor.getLastCursor().moveToTop() + editor.deleteLine() + } + expect(buffer.getLineCount()).toBe(1) + expect(buffer.getText()).toBe('') + }) + + describe('when soft wrap is enabled', () => { + it('deletes the entire line that the cursor is on', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + editor.setCursorBufferPosition([6]) + + const line7 = buffer.lineForRow(7) + const count = buffer.getLineCount() + expect(buffer.lineForRow(6)).not.toBe(line7) + editor.deleteLine() + expect(buffer.lineForRow(6)).toBe(line7) + expect(buffer.getLineCount()).toBe(count - 1) + }) + }) + + describe('when the line being deleted precedes a fold, and the command is undone', () => { + it('restores the line and preserves the fold', () => { + editor.setCursorBufferPosition([4]) + editor.foldCurrentRow() + expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() + editor.setCursorBufferPosition([3]) + editor.deleteLine() + expect(editor.isFoldedAtScreenRow(3)).toBeTruthy() + expect(buffer.lineForRow(3)).toBe(' while(items.length > 0) {') + editor.undo() + expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + }) + + describe('.replaceSelectedText(options, fn)', () => { + describe('when no text is selected', () => { + it('inserts the text returned from the function at the cursor position', () => { + editor.replaceSelectedText({}, () => '123') + expect(buffer.lineForRow(0)).toBe('123var quicksort = function () {') + + editor.setCursorBufferPosition([0]) + editor.replaceSelectedText({selectWordIfEmpty: true}, () => 'var') + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + + editor.setCursorBufferPosition([10]) + editor.replaceSelectedText(null, () => '') + expect(buffer.lineForRow(10)).toBe('') + }) + }) + + describe('when text is selected', () => { + it('replaces the selected text with the text returned from the function', () => { + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + editor.replaceSelectedText({}, () => 'ia') + expect(buffer.lineForRow(0)).toBe('via quicksort = function () {') + }) + + it('replaces the selected text and selects the replacement text', () => { + editor.setSelectedBufferRange([[0, 4], [0, 9]]) + editor.replaceSelectedText({}, () => 'whatnot') + expect(buffer.lineForRow(0)).toBe('var whatnotsort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 4], [0, 11]]) + }) + }) + }) + + describe('.transpose()', () => { + it('swaps two characters', () => { + editor.buffer.setText('abc') + editor.setCursorScreenPosition([0, 1]) + editor.transpose() + expect(editor.lineTextForBufferRow(0)).toBe('bac') + }) + + it('reverses a selection', () => { + editor.buffer.setText('xabcz') + editor.setSelectedBufferRange([[0, 1], [0, 4]]) + editor.transpose() + expect(editor.lineTextForBufferRow(0)).toBe('xcbaz') + }) + }) + + describe('.upperCase()', () => { + describe('when there is no selection', () => { + it('upper cases the current word', () => { + editor.buffer.setText('aBc') + editor.setCursorScreenPosition([0, 1]) + editor.upperCase() + expect(editor.lineTextForBufferRow(0)).toBe('ABC') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when there is a selection', () => { + it('upper cases the current selection', () => { + editor.buffer.setText('abc') + editor.setSelectedBufferRange([[0, 0], [0, 2]]) + editor.upperCase() + expect(editor.lineTextForBufferRow(0)).toBe('ABc') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 2]]) + }) + }) + }) + + describe('.lowerCase()', () => { + describe('when there is no selection', () => { + it('lower cases the current word', () => { + editor.buffer.setText('aBC') + editor.setCursorScreenPosition([0, 1]) + editor.lowerCase() + expect(editor.lineTextForBufferRow(0)).toBe('abc') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when there is a selection', () => { + it('lower cases the current selection', () => { + editor.buffer.setText('ABC') + editor.setSelectedBufferRange([[0, 0], [0, 2]]) + editor.lowerCase() + expect(editor.lineTextForBufferRow(0)).toBe('abC') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 2]]) + }) + }) + }) + + describe('.setTabLength(tabLength)', () => { + it('clips atomic soft tabs to the given tab length', () => { + expect(editor.getTabLength()).toBe(2) + expect(editor.clipScreenPosition([5, 1], {clipDirection: 'forward'})).toEqual([5, 2]) + + editor.setTabLength(6) + expect(editor.getTabLength()).toBe(6) + expect(editor.clipScreenPosition([5, 1], {clipDirection: 'forward'})).toEqual([5, 6]) + + const changeHandler = jasmine.createSpy('changeHandler') + editor.onDidChange(changeHandler) + editor.setTabLength(6) + expect(changeHandler).not.toHaveBeenCalled() + }) + + it('does not change its tab length when the given tab length is null', () => { + editor.setTabLength(4) + editor.setTabLength(null) + expect(editor.getTabLength()).toBe(4) + }) + }) + + describe('.indentLevelForLine(line)', () => { + it('returns the indent level when the line has only leading whitespace', () => { + expect(editor.indentLevelForLine(' hello')).toBe(2) + expect(editor.indentLevelForLine(' hello')).toBe(1.5) + }) + + it('returns the indent level when the line has only leading tabs', () => expect(editor.indentLevelForLine('\t\thello')).toBe(2)) + + it('returns the indent level based on the character starting the line when the leading whitespace contains both spaces and tabs', () => { + expect(editor.indentLevelForLine('\t hello')).toBe(2) + expect(editor.indentLevelForLine(' \thello')).toBe(2) + expect(editor.indentLevelForLine(' \t hello')).toBe(2.5) + expect(editor.indentLevelForLine(' \t \thello')).toBe(4) + expect(editor.indentLevelForLine(' \t \thello')).toBe(4) + expect(editor.indentLevelForLine(' \t \t hello')).toBe(4.5) + }) + }) + + describe('when a better-matched grammar is added to syntax', () => { + it('switches to the better-matched grammar and re-tokenizes the buffer', async () => { + editor.destroy() + + const jsGrammar = atom.grammars.selectGrammar('a.js') + atom.grammars.removeGrammar(jsGrammar) + + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + + expect(editor.getGrammar()).toBe(atom.grammars.nullGrammar) + expect(editor.tokensForScreenRow(0).length).toBe(1) + + atom.grammars.addGrammar(jsGrammar) + expect(editor.getGrammar()).toBe(jsGrammar) + expect(editor.tokensForScreenRow(0).length).toBeGreaterThan(1) + }) + }) + + describe('editor.autoIndent', () => { + describe('when editor.autoIndent is false (default)', () => { + describe('when `indent` is triggered', () => { + it('does not auto-indent the line', () => { + editor.setCursorBufferPosition([1, 30]) + editor.insertText('\n ') + expect(editor.lineTextForBufferRow(2)).toBe(' ') + + editor.update({autoIndent: false}) + editor.indent() + expect(editor.lineTextForBufferRow(2)).toBe(' ') + }) + }) + }) + + describe('when editor.autoIndent is true', () => { + beforeEach(() => editor.update({autoIndent: true})) + + describe('when `indent` is triggered', () => { + it('auto-indents the line', () => { + editor.setCursorBufferPosition([1, 30]) + editor.insertText('\n ') + expect(editor.lineTextForBufferRow(2)).toBe(' ') + + editor.update({autoIndent: true}) + editor.indent() + expect(editor.lineTextForBufferRow(2)).toBe(' ') + }) + }) + + describe('when a newline is added', () => { + describe('when the line preceding the newline adds a new level of indentation', () => { + it('indents the newline to one additional level of indentation beyond the preceding line', () => { + editor.setCursorBufferPosition([1, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + }) + }) + + describe("when the line preceding the newline doesn't add a level of indentation", () => { + it('indents the new line to the same level as the preceding line', () => { + editor.setCursorBufferPosition([5, 14]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(6)).toBe(editor.indentationForBufferRow(5)) + }) + }) + + describe('when the line preceding the newline is a comment', () => { + it('maintains the indent of the commented line', () => { + editor.setCursorBufferPosition([0, 0]) + editor.insertText(' //') + editor.setCursorBufferPosition([0, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(1)).toBe(2) + }) + }) + + describe('when the line preceding the newline contains only whitespace', () => { + it("bases the new line's indentation on only the preceding line", () => { + editor.setCursorBufferPosition([6, Infinity]) + editor.insertText('\n ') + expect(editor.getCursorBufferPosition()).toEqual([7, 2]) + + editor.insertNewline() + expect(editor.lineTextForBufferRow(8)).toBe(' ') + }) + }) + + it('does not indent the line preceding the newline', () => { + editor.setCursorBufferPosition([2, 0]) + editor.insertText(' var this-line-should-be-indented-more\n') + expect(editor.indentationForBufferRow(1)).toBe(1) + + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([2, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(1) + }) + + describe('when the cursor is before whitespace', () => { + it('retains the whitespace following the cursor on the new line', () => { + editor.setText(' var sort = function() {}') + editor.setCursorScreenPosition([0, 12]) + editor.insertNewline() + + expect(buffer.lineForRow(0)).toBe(' var sort =') + expect(buffer.lineForRow(1)).toBe(' function() {}') + expect(editor.getCursorScreenPosition()).toEqual([1, 2]) + }) + }) + }) + + describe('when inserted text matches a decrease indent pattern', () => { + describe('when the preceding line matches an increase indent pattern', () => { + it('decreases the indentation to match that of the preceding line', () => { + editor.setCursorBufferPosition([1, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + editor.insertText('}') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1)) + }) + }) + + describe("when the preceding line doesn't match an increase indent pattern", () => { + it('decreases the indentation to be one level below that of the preceding line', () => { + editor.setCursorBufferPosition([3, Infinity]) + editor.insertText('\n ') + expect(editor.indentationForBufferRow(4)).toBe(editor.indentationForBufferRow(3)) + editor.insertText('}') + expect(editor.indentationForBufferRow(4)).toBe(editor.indentationForBufferRow(3) - 1) + }) + + it("doesn't break when decreasing the indentation on a row that has no indentation", () => { + editor.setCursorBufferPosition([12, Infinity]) + editor.insertText('\n}; # too many closing brackets!') + expect(editor.lineTextForBufferRow(13)).toBe('}; # too many closing brackets!') + }) + }) + }) + + describe('when inserted text does not match a decrease indent pattern', () => { + it('does not decrease the indentation', () => { + editor.setCursorBufferPosition([12, 0]) + editor.insertText(' ') + expect(editor.lineTextForBufferRow(12)).toBe(' };') + editor.insertText('\t\t') + expect(editor.lineTextForBufferRow(12)).toBe(' \t\t};') + }) + }) + + describe('when the current line does not match a decrease indent pattern', () => { + it('leaves the line unchanged', () => { + editor.setCursorBufferPosition([2, 4]) + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + editor.insertText('foo') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + }) + }) + }) + }) + + describe('atomic soft tabs', () => { + it('skips tab-length runs of leading whitespace when moving the cursor', () => { + editor.update({tabLength: 4, atomicSoftTabs: true}) + + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 4]) + + editor.update({atomicSoftTabs: false}) + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 3]) + + editor.update({atomicSoftTabs: true}) + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 4]) + }) + }) + + describe('.destroy()', () => { + it('destroys marker layers associated with the text editor', () => { + buffer.retain() + const selectionsMarkerLayerId = editor.selectionsMarkerLayer.id + const foldsMarkerLayerId = editor.displayLayer.foldsMarkerLayer.id + editor.destroy() + expect(buffer.getMarkerLayer(selectionsMarkerLayerId)).toBeUndefined() + expect(buffer.getMarkerLayer(foldsMarkerLayerId)).toBeUndefined() + buffer.release() + }) + + it('notifies ::onDidDestroy observers when the editor is destroyed', () => { + let destroyObserverCalled = false + editor.onDidDestroy(() => destroyObserverCalled = true) + + editor.destroy() + expect(destroyObserverCalled).toBe(true) + }) + + it('does not blow up when query methods are called afterward', () => { + editor.destroy() + editor.getGrammar() + editor.getLastCursor() + editor.lineTextForBufferRow(0) + }) + + it("emits the destroy event after destroying the editor's buffer", () => { + const events = [] + editor.getBuffer().onDidDestroy(() => { + expect(editor.isDestroyed()).toBe(true) + events.push('buffer-destroyed') + }) + editor.onDidDestroy(() => { + expect(buffer.isDestroyed()).toBe(true) + events.push('editor-destroyed') + }) + editor.destroy() + expect(events).toEqual(['buffer-destroyed', 'editor-destroyed']) + }) + }) + + describe('.joinLines()', () => { + describe('when no text is selected', () => { + describe("when the line below isn't empty", () => { + it('joins the line below with the current line separated by a space and moves the cursor to the start of line that was moved up', () => { + editor.setCursorBufferPosition([0, Infinity]) + editor.insertText(' ') + editor.setCursorBufferPosition([0]) + editor.joinLines() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) {') + expect(editor.getCursorBufferPosition()).toEqual([0, 29]) + }) + }) + + describe('when the line below is empty', () => { + it('deletes the line below and moves the cursor to the end of the line', () => { + editor.setCursorBufferPosition([9]) + editor.joinLines() + expect(editor.lineTextForBufferRow(9)).toBe(' };') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getCursorBufferPosition()).toEqual([9, 4]) + }) + }) + + describe('when the cursor is on the last row', () => { + it('does nothing', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + editor.joinLines() + expect(editor.lineTextForBufferRow(12)).toBe('};') + }) + }) + + describe('when the line is empty', () => { + it('joins the line below with the current line with no added space', () => { + editor.setCursorBufferPosition([10]) + editor.joinLines() + expect(editor.lineTextForBufferRow(10)).toBe('return sort(Array.apply(this, arguments));') + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + }) + }) + + describe('when text is selected', () => { + describe('when the selection does not span multiple lines', () => { + it('joins the line below with the current line separated by a space and retains the selected text', () => { + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + editor.joinLines() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [0, 3]]) + }) + }) + + describe('when the selection spans multiple lines', () => { + it('joins all selected lines separated by a space and retains the selected text', () => { + editor.setSelectedBufferRange([[9, 3], [12, 1]]) + editor.joinLines() + expect(editor.lineTextForBufferRow(9)).toBe(' }; return sort(Array.apply(this, arguments)); };') + expect(editor.getSelectedBufferRange()).toEqual([[9, 3], [9, 49]]) + }) + }) + }) + }) + + describe('.duplicateLines()', () => { + it('for each selection, duplicates all buffer lines intersected by the selection', () => { + editor.foldBufferRow(4) + editor.setCursorBufferPosition([2, 5]) + editor.addSelectionForBufferRange([[3, 0], [8, 0]], {preserveFolds: true}) + + editor.duplicateLines() + + expect(editor.getTextInBufferRange([[2, 0], [13, 5]])).toBe(`\ +\ if (items.length <= 1) return items; + if (items.length <= 1) return items; + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + } + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + }\ +` + ) + expect(editor.getSelectedBufferRanges()).toEqual([[[3, 5], [3, 5]], [[9, 0], [14, 0]]]) + + // folds are also duplicated + expect(editor.isFoldedAtScreenRow(5)).toBe(true) + expect(editor.isFoldedAtScreenRow(7)).toBe(true) + expect(editor.lineTextForScreenRow(7)).toBe(` while(items.length > 0) {${editor.displayLayer.foldCharacter}`) + expect(editor.lineTextForScreenRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + }) + + it('duplicates all folded lines for empty selections on lines containing folds', () => { + editor.foldBufferRow(4) + editor.setCursorBufferPosition([4, 0]) + + editor.duplicateLines() + + expect(editor.getTextInBufferRange([[2, 0], [11, 5]])).toBe(`\ +\ if (items.length <= 1) return items; + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + } + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + }\ +` + ) + expect(editor.getSelectedBufferRange()).toEqual([[8, 0], [8, 0]]) + }) + + it('can duplicate the last line of the buffer', () => { + editor.setSelectedBufferRange([[11, 0], [12, 2]]) + editor.duplicateLines() + expect(editor.getTextInBufferRange([[11, 0], [14, 2]])).toBe(`\ +\ return sort(Array.apply(this, arguments)); +}; + return sort(Array.apply(this, arguments)); +};\ +` + ) + expect(editor.getSelectedBufferRange()).toEqual([[13, 0], [14, 2]]) + }) + + it('only duplicates lines containing multiple selections once', () => { + editor.setText(`\ +aaaaaa +bbbbbb +cccccc +dddddd\ +`) + editor.setSelectedBufferRanges([ + [[0, 1], [0, 2]], + [[0, 3], [0, 4]], + [[2, 1], [2, 2]], + [[2, 3], [3, 1]], + [[3, 3], [3, 4]] + ]) + editor.duplicateLines() + expect(editor.getText()).toBe(`\ +aaaaaa +aaaaaa +bbbbbb +cccccc +dddddd +cccccc +dddddd\ +`) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 1], [1, 2]], + [[1, 3], [1, 4]], + [[5, 1], [5, 2]], + [[5, 3], [6, 1]], + [[6, 3], [6, 4]] + ]) + }) + }) + + describe('when the editor contains surrogate pair characters', () => { + it('correctly backspaces over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') + editor.moveToBottom() + editor.backspace() + expect(editor.getText()).toBe('\uD835\uDF97\uD835\uDF97') + editor.backspace() + expect(editor.getText()).toBe('\uD835\uDF97') + editor.backspace() + expect(editor.getText()).toBe('') + }) + + it('correctly deletes over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') + editor.moveToTop() + editor.delete() + expect(editor.getText()).toBe('\uD835\uDF97\uD835\uDF97') + editor.delete() + expect(editor.getText()).toBe('\uD835\uDF97') + editor.delete() + expect(editor.getText()).toBe('') + }) + + it('correctly moves over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97\n') + editor.moveToTop() + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('when the editor contains variation sequence character pairs', () => { + it('correctly backspaces over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') + editor.moveToBottom() + editor.backspace() + expect(editor.getText()).toBe('\u2714\uFE0E\u2714\uFE0E') + editor.backspace() + expect(editor.getText()).toBe('\u2714\uFE0E') + editor.backspace() + expect(editor.getText()).toBe('') + }) + + it('correctly deletes over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') + editor.moveToTop() + editor.delete() + expect(editor.getText()).toBe('\u2714\uFE0E\u2714\uFE0E') + editor.delete() + expect(editor.getText()).toBe('\u2714\uFE0E') + editor.delete() + expect(editor.getText()).toBe('') + }) + + it('correctly moves over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E\n') + editor.moveToTop() + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.setIndentationForBufferRow', () => { + describe('when the editor uses soft tabs but the row has hard tabs', () => { + it('only replaces whitespace characters', () => { + editor.setSoftWrapped(true) + editor.setText('\t1\n\t2') + editor.setCursorBufferPosition([0, 0]) + editor.setIndentationForBufferRow(0, 2) + expect(editor.getText()).toBe(' 1\n\t2') + }) + }) + + describe('when the indentation level is a non-integer', () => { + it('does not throw an exception', () => { + editor.setSoftWrapped(true) + editor.setText('\t1\n\t2') + editor.setCursorBufferPosition([0, 0]) + editor.setIndentationForBufferRow(0, 2.1) + expect(editor.getText()).toBe(' 1\n\t2') + }) + }) + }) + + describe("when the editor's grammar has an injection selector", () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-text') + await atom.packages.activatePackage('language-javascript') + }) + + it("includes the grammar's patterns when the selector matches the current scope in other grammars", async () => { + await atom.packages.activatePackage('language-hyperlink') + + const grammar = atom.grammars.selectGrammar('text.js') + const {line, tags} = grammar.tokenizeLine('var i; // http://github.com') + + const tokens = atom.grammars.decodeTokens(line, tags) + expect(tokens[0].value).toBe('var') + expect(tokens[0].scopes).toEqual(['source.js', 'storage.type.var.js']) + expect(tokens[6].value).toBe('http://github.com') + expect(tokens[6].scopes).toEqual(['source.js', 'comment.line.double-slash.js', 'markup.underline.link.http.hyperlink']) + }) + + describe('when the grammar is added', () => { + it('retokenizes existing buffers that contain tokens that match the injection selector', async () => { + editor = await atom.workspace.open('sample.js') + editor.setText('// http://github.com') + let tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('language-hyperlink') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--markup syntax--underline syntax--link syntax--http syntax--hyperlink']} + ]) + }) + + describe('when the grammar is updated', () => { + it('retokenizes existing buffers that contain tokens that match the injection selector', async () => { + editor = await atom.workspace.open('sample.js') + editor.setText('// SELECT * FROM OCTOCATS') + let tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('package-with-injection-selector') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('language-sql') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'SELECT', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: '*', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--operator syntax--star syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'FROM', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + }) + }) + }) + }) + + describe('.normalizeTabsInBufferRange()', () => { + it("normalizes tabs depending on the editor's soft tab/tab length settings", () => { + editor.setTabLength(1) + editor.setSoftTabs(true) + editor.setText('\t\t\t') + editor.normalizeTabsInBufferRange([[0, 0], [0, 1]]) + expect(editor.getText()).toBe(' \t\t') + + editor.setTabLength(2) + editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) + expect(editor.getText()).toBe(' ') + + editor.setSoftTabs(false) + editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) + expect(editor.getText()).toBe(' ') + }) + }) + + describe('.pageUp/Down()', () => { + it('moves the cursor down one page length', () => { + editor.update({autoHeight: false}) + const element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = (element.component.getLineHeight() * 5) + 'px' + element.measureDimensions() + + expect(editor.getCursorBufferPosition().row).toBe(0) + + editor.pageDown() + expect(editor.getCursorBufferPosition().row).toBe(5) + + editor.pageDown() + expect(editor.getCursorBufferPosition().row).toBe(10) + + editor.pageUp() + expect(editor.getCursorBufferPosition().row).toBe(5) + + editor.pageUp() + expect(editor.getCursorBufferPosition().row).toBe(0) + }) + }) + + describe('.selectPageUp/Down()', () => { + it('selects one screen height of text up or down', () => { + editor.update({autoHeight: false}) + const element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = (element.component.getLineHeight() * 5) + 'px' + element.measureDimensions() + + expect(editor.getCursorBufferPosition().row).toBe(0) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [5, 0]]]) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [10, 0]]]) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [12, 2]]]) + + editor.moveToBottom() + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[7, 0], [12, 2]]]) + + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 0], [12, 2]]]) + + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [12, 2]]]) + }) + }) + + describe('::scrollToScreenPosition(position, [options])', () => { + it('triggers ::onDidRequestAutoscroll with the logical coordinates along with the options', () => { + const scrollSpy = jasmine.createSpy('::onDidRequestAutoscroll') + editor.onDidRequestAutoscroll(scrollSpy) + + editor.scrollToScreenPosition([8, 20]) + editor.scrollToScreenPosition([8, 20], {center: true}) + editor.scrollToScreenPosition([8, 20], {center: false, reversed: true}) + + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {}}) + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {center: true}}) + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {center: false, reversed: true}}) + }) + }) + + describe('scroll past end', () => { + it('returns false by default but can be customized', () => { + expect(editor.getScrollPastEnd()).toBe(false) + editor.update({scrollPastEnd: true}) + expect(editor.getScrollPastEnd()).toBe(true) + editor.update({scrollPastEnd: false}) + expect(editor.getScrollPastEnd()).toBe(false) + }) + + it('always returns false when autoHeight is on', () => { + editor.update({autoHeight: true, scrollPastEnd: true}) + expect(editor.getScrollPastEnd()).toBe(false) + editor.update({autoHeight: false}) + expect(editor.getScrollPastEnd()).toBe(true) + }) + }) + + describe('auto height', () => { + it('returns true by default but can be customized', () => { + editor = new TextEditor() + expect(editor.getAutoHeight()).toBe(true) + editor.update({autoHeight: false}) + expect(editor.getAutoHeight()).toBe(false) + editor.update({autoHeight: true}) + expect(editor.getAutoHeight()).toBe(true) + editor.destroy() + }) + }) + + describe('auto width', () => { + it('returns false by default but can be customized', () => { + expect(editor.getAutoWidth()).toBe(false) + editor.update({autoWidth: true}) + expect(editor.getAutoWidth()).toBe(true) + editor.update({autoWidth: false}) + expect(editor.getAutoWidth()).toBe(false) + }) + }) + + describe('.get/setPlaceholderText()', () => { + it('can be created with placeholderText', () => { + const newEditor = new TextEditor({ + mini: true, + placeholderText: 'yep' + }) + expect(newEditor.getPlaceholderText()).toBe('yep') + }) + + it('models placeholderText and emits an event when changed', () => { + let handler + editor.onDidChangePlaceholderText(handler = jasmine.createSpy()) + + expect(editor.getPlaceholderText()).toBeUndefined() + + editor.setPlaceholderText('OK') + expect(handler).toHaveBeenCalledWith('OK') + expect(editor.getPlaceholderText()).toBe('OK') + }) + }) + + describe('gutters', () => { + describe('the TextEditor constructor', () => { + it('creates a line-number gutter', () => { + expect(editor.getGutters().length).toBe(1) + const lineNumberGutter = editor.gutterWithName('line-number') + expect(lineNumberGutter.name).toBe('line-number') + expect(lineNumberGutter.priority).toBe(0) + }) + }) + + describe('::addGutter', () => { + it('can add a gutter', () => { + expect(editor.getGutters().length).toBe(1) // line-number gutter + const options = { + name: 'test-gutter', + priority: 1 + } + const gutter = editor.addGutter(options) + expect(editor.getGutters().length).toBe(2) + expect(editor.getGutters()[1]).toBe(gutter) + }) + + it("does not allow a custom gutter with the 'line-number' name.", () => expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow()) + }) + + describe('::decorateMarker', () => { + let marker + + beforeEach(() => marker = editor.markBufferRange([[1, 0], [1, 0]])) + + it('reflects an added decoration when one of its custom gutters is decorated.', () => { + const gutter = editor.addGutter({'name': 'custom-gutter'}) + const decoration = gutter.decorateMarker(marker, {class: 'custom-class'}) + const gutterDecorations = editor.getDecorations({ + type: 'gutter', + gutterName: 'custom-gutter', + class: 'custom-class' + }) + expect(gutterDecorations.length).toBe(1) + expect(gutterDecorations[0]).toBe(decoration) + }) + + it('reflects an added decoration when its line-number gutter is decorated.', () => { + const decoration = editor.gutterWithName('line-number').decorateMarker(marker, {class: 'test-class'}) + const gutterDecorations = editor.getDecorations({ + type: 'line-number', + gutterName: 'line-number', + class: 'test-class' + }) + expect(gutterDecorations.length).toBe(1) + expect(gutterDecorations[0]).toBe(decoration) + }) + }) + + describe('::observeGutters', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback immediately with each existing gutter, and with each added gutter after that.', () => { + const lineNumberGutter = editor.gutterWithName('line-number') + editor.observeGutters(callback) + expect(payloads).toEqual([lineNumberGutter]) + const gutter1 = editor.addGutter({name: 'test-gutter-1'}) + expect(payloads).toEqual([lineNumberGutter, gutter1]) + const gutter2 = editor.addGutter({name: 'test-gutter-2'}) + expect(payloads).toEqual([lineNumberGutter, gutter1, gutter2]) + }) + + it('does not call the callback when a gutter is removed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + editor.observeGutters(callback) + payloads = [] + gutter.destroy() + expect(payloads).toEqual([]) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const subscription = editor.observeGutters(callback) + payloads = [] + subscription.dispose() + editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([]) + }) + }) + + describe('::onDidAddGutter', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback with each newly-added gutter, but not with existing gutters.', () => { + editor.onDidAddGutter(callback) + expect(payloads).toEqual([]) + const gutter = editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([gutter]) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const subscription = editor.onDidAddGutter(callback) + payloads = [] + subscription.dispose() + editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([]) + }) + }) + + describe('::onDidRemoveGutter', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback when a gutter is removed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + editor.onDidRemoveGutter(callback) + expect(payloads).toEqual([]) + gutter.destroy() + expect(payloads).toEqual(['test-gutter']) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + const subscription = editor.onDidRemoveGutter(callback) + subscription.dispose() + gutter.destroy() + expect(payloads).toEqual([]) + }) + }) + }) + + describe('decorations', () => { + describe('::decorateMarker', () => { + it('includes the decoration in the object returned from ::decorationsStateForScreenRowRange', () => { + const marker = editor.markBufferRange([[2, 4], [6, 8]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'foo'}) + expect(editor.decorationsStateForScreenRowRange(0, 5)[decoration.id]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker.getScreenRange(), + bufferRange: marker.getBufferRange(), + rangeIsReversed: false + }) + }) + + it("does not throw errors after the marker's containing layer is destroyed", () => { + const layer = editor.addMarkerLayer() + const marker = layer.markBufferRange([[2, 4], [6, 8]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'foo'}) + layer.destroy() + editor.decorationsStateForScreenRowRange(0, 5) + }) + }) + + describe('::decorateMarkerLayer', () => { + it('based on the markers in the layer, includes multiple decoration objects with the same properties and different ranges in the object returned from ::decorationsStateForScreenRowRange', () => { + const layer1 = editor.getBuffer().addMarkerLayer() + const marker1 = layer1.markRange([[2, 4], [6, 8]]) + const marker2 = layer1.markRange([[11, 0], [11, 12]]) + const layer2 = editor.getBuffer().addMarkerLayer() + const marker3 = layer2.markRange([[8, 0], [9, 0]]) + + const layer1Decoration1 = editor.decorateMarkerLayer(layer1, {type: 'highlight', class: 'foo'}) + const layer1Decoration2 = editor.decorateMarkerLayer(layer1, {type: 'highlight', class: 'bar'}) + const layer2Decoration = editor.decorateMarkerLayer(layer2, {type: 'highlight', class: 'baz'}) + + let decorationState = editor.decorationsStateForScreenRowRange(0, 13) + + expect(decorationState[`${layer1Decoration1.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration1.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer2Decoration.id}-${marker3.id}`]).toEqual({ + properties: {type: 'highlight', class: 'baz'}, + screenRange: marker3.getRange(), + bufferRange: marker3.getRange(), + rangeIsReversed: false + }) + + layer1Decoration1.destroy() + + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration1.id}-${marker1.id}`]).toBeUndefined() + expect(decorationState[`${layer1Decoration1.id}-${marker2.id}`]).toBeUndefined() + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer2Decoration.id}-${marker3.id}`]).toEqual({ + properties: {type: 'highlight', class: 'baz'}, + screenRange: marker3.getRange(), + bufferRange: marker3.getRange(), + rangeIsReversed: false + }) + + layer1Decoration2.setPropertiesForMarker(marker1, {type: 'highlight', class: 'quux'}) + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'quux'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + + layer1Decoration2.setPropertiesForMarker(marker1, null) + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + }) + }) + }) + + describe('invisibles', () => { + beforeEach(() => { + editor.update({showInvisibles: true}) + }) + + it('substitutes invisible characters according to the given rules', () => { + const previousLineText = editor.lineTextForScreenRow(0) + editor.update({invisibles: {eol: '?'}}) + expect(editor.lineTextForScreenRow(0)).not.toBe(previousLineText) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) + expect(editor.getInvisibles()).toEqual({eol: '?'}) + }) + + it('does not use invisibles if showInvisibles is set to false', () => { + editor.update({invisibles: {eol: '?'}}) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) + + editor.update({showInvisibles: false}) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(false) + }) + }) + + describe('indent guides', () => { + it('shows indent guides when `editor.showIndentGuide` is set to true and the editor is not mini', () => { + editor.setText(' foo') + editor.setTabLength(2) + + editor.update({showIndentGuide: false}) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + + editor.update({showIndentGuide: true}) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace indent-guide']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + + editor.setMini(true) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + }) + }) + + describe('when the editor is constructed with the grammar option set', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-coffee-script') + }) + + it('sets the grammar', () => { + editor = new TextEditor({grammar: atom.grammars.grammarForScopeName('source.coffee')}) + expect(editor.getGrammar().name).toBe('CoffeeScript') + }) + }) + + describe('softWrapAtPreferredLineLength', () => { + it('soft wraps the editor at the preferred line length unless the editor is narrower or the editor is mini', () => { + editor.update({ + editorWidthInChars: 30, + softWrapped: true, + softWrapAtPreferredLineLength: true, + preferredLineLength: 20 + }) + + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = ') + + editor.update({editorWidthInChars: 10}) + expect(editor.lineTextForScreenRow(0)).toBe('var ') + + editor.update({mini: true}) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + }) + }) + + describe('softWrapHangingIndentLength', () => { + it('controls how much extra indentation is applied to soft-wrapped lines', () => { + editor.setText('123456789') + editor.update({ + editorWidthInChars: 8, + softWrapped: true, + softWrapHangingIndentLength: 2 + }) + expect(editor.lineTextForScreenRow(1)).toEqual(' 9') + + editor.update({softWrapHangingIndentLength: 4}) + expect(editor.lineTextForScreenRow(1)).toEqual(' 9') + }) + }) + + describe('::getElement', () => { + it('returns an element', () => expect(editor.getElement() instanceof HTMLElement).toBe(true)) + }) + + describe('setMaxScreenLineLength', () => { + it('sets the maximum line length in the editor before soft wrapping is forced', () => { + expect(editor.getSoftWrapColumn()).toBe(500) + editor.update({ + maxScreenLineLength: 1500 + }) + expect(editor.getSoftWrapColumn()).toBe(1500) + }) + }) +}) describe('TextEditor', () => { let editor @@ -58,6 +6719,276 @@ describe('TextEditor', () => { }) }) + describe('.toggleLineCommentsInSelection()', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + editor = await atom.workspace.open('sample.js') + }) + + it('toggles comments on the selected lines', () => { + editor.setSelectedBufferRange([[4, 5], [7, 5]]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + expect(editor.getSelectedBufferRange()).toEqual([[4, 8], [7, 8]]) + + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' }') + }) + + it('does not comment the last line of a non-empty selection if it ends at column 0', () => { + editor.setSelectedBufferRange([[4, 5], [7, 0]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' }') + }) + + it('uncomments lines if all lines match the comment regex', () => { + editor.setSelectedBufferRange([[0, 0], [0, 1]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// // var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('// var sort = function(items) {') + expect(editor.lineTextForBufferRow(2)).toBe('// if (items.length <= 1) return items;') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + + editor.setSelectedBufferRange([[0, 0], [0, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + }) + + it('uncomments commented lines separated by an empty line', () => { + editor.setSelectedBufferRange([[0, 0], [1, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('// var sort = function(items) {') + + editor.getBuffer().insert([0, Infinity], '\n') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + }) + + it('preserves selection emptiness', () => { + editor.setCursorBufferPosition([4, 0]) + editor.toggleLineCommentsInSelection() + expect(editor.getLastSelection().isEmpty()).toBeTruthy() + }) + + it('does not explode if the current language mode has no comment regex', () => { + const editor = new TextEditor({buffer: new TextBuffer({text: 'hello'})}) + editor.setSelectedBufferRange([[0, 0], [0, 5]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('hello') + }) + + it('does nothing for empty lines and null grammar', () => { + editor.setGrammar(atom.grammars.grammarForScopeName('text.plain.null-grammar')) + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe('') + }) + + it('uncomments when the line lacks the trailing whitespace in the comment regex', () => { + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(10)).toBe('// ') + expect(editor.getSelectedBufferRange()).toEqual([[10, 3], [10, 3]]) + editor.backspace() + expect(editor.lineTextForBufferRow(10)).toBe('//') + + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe('') + expect(editor.getSelectedBufferRange()).toEqual([[10, 0], [10, 0]]) + }) + + it('uncomments when the line has leading whitespace', () => { + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(10)).toBe('// ') + editor.moveToBeginningOfLine() + editor.insertText(' ') + editor.setSelectedBufferRange([[10, 0], [10, 0]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe(' ') + }) + }) + + describe('.toggleLineCommentsForBufferRows', () => { + describe('xml', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-xml') + editor = await atom.workspace.open('test.xml') + editor.setText('') + }) + + it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => { + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('test') + }) + }) + + describe('less', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-less') + await atom.packages.activatePackage('language-css') + editor = await atom.workspace.open('sample.less') + }) + + it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => { + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('// @color: #4D926F;') + }) + }) + + describe('css', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-css') + editor = await atom.workspace.open('css.css') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe('/* body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px; */') + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%;') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(0)).toBe('/* body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px; */') + expect(editor.lineTextForBufferRow(2)).toBe(' /* width: 110%; */') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe('body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px;') + expect(editor.lineTextForBufferRow(2)).toBe(' /* width: 110%; */') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + }) + + it('uncomments lines with leading whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], ' /* width: 110%; */') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%;') + }) + + it('uncomments lines with trailing whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], '/* width: 110%; */ ') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe('width: 110%; ') + }) + + it('uncomments lines with leading and trailing whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], ' /* width: 110%; */ ') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%; ') + }) + }) + + describe('coffeescript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-coffee-script') + editor = await atom.workspace.open('coffee.coffee') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(4, 6) + expect(editor.lineTextForBufferRow(4)).toBe(' # pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' # left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + }) + + it('comments/uncomments empty lines', () => { + editor.toggleLineCommentsForBufferRows(4, 7) + expect(editor.lineTextForBufferRow(4)).toBe(' # pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' # left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + expect(editor.lineTextForBufferRow(7)).toBe(' # ') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + expect(editor.lineTextForBufferRow(7)).toBe(' # ') + }) + }) + + describe('javascript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + editor = await atom.workspace.open('sample.js') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(4, 7) + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + + editor.setText('\tvar i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('\t// var i;') + + editor.setText('var i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('// var i;') + + editor.setText(' var i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe(' // var i;') + + editor.setText(' ') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe(' // ') + + editor.setText(' a\n \n b') + editor.toggleLineCommentsForBufferRows(0, 2) + expect(editor.lineTextForBufferRow(0)).toBe(' // a') + expect(editor.lineTextForBufferRow(1)).toBe(' // ') + expect(editor.lineTextForBufferRow(2)).toBe(' // b') + + editor.setText(' \n // var i;') + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe(' ') + expect(editor.lineTextForBufferRow(1)).toBe(' var i;') + }) + }) + }) + describe('folding', () => { beforeEach(async () => { await atom.packages.activatePackage('language-javascript') @@ -173,6 +7104,26 @@ describe('TextEditor', () => { }) }) + describe('.foldCurrentRow()', () => { + it('creates a fold at the location of the last cursor', async () => { + editor = await atom.workspace.open() + editor.setText('\nif (x) {\n y()\n}') + editor.setCursorBufferPosition([1, 0]) + expect(editor.getScreenLineCount()).toBe(4) + editor.foldCurrentRow() + expect(editor.getScreenLineCount()).toBe(3) + }) + + it('does nothing when the current row cannot be folded', async () => { + editor = await atom.workspace.open() + editor.setText('var x;\nx++\nx++') + editor.setCursorBufferPosition([0, 0]) + expect(editor.getScreenLineCount()).toBe(3) + editor.foldCurrentRow() + expect(editor.getScreenLineCount()).toBe(3) + }) + }) + describe('.foldAllAtIndentLevel(indentLevel)', () => { it('folds blocks of text at the given indentation level', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) @@ -247,3 +7198,7 @@ describe('TextEditor', () => { }) }) }) + +function convertToHardTabs (buffer) { + buffer.setText(buffer.getText().replace(/[ ]{2}/g, '\t')) +} diff --git a/spec/tokenized-buffer-spec.js b/spec/tokenized-buffer-spec.js index ba43f9ff3..b1574673a 100644 --- a/spec/tokenized-buffer-spec.js +++ b/spec/tokenized-buffer-spec.js @@ -643,186 +643,6 @@ describe('TokenizedBuffer', () => { }) }) - describe('.toggleLineCommentsForBufferRows', () => { - describe('xml', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-xml') - buffer = new TextBuffer('') - tokenizedBuffer = new TokenizedBuffer({ - buffer, - grammar: atom.grammars.grammarForScopeName('text.xml'), - scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) - }) - }) - - it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe('test') - }) - }) - - describe('less', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-less') - await atom.packages.activatePackage('language-css') - buffer = await TextBuffer.load(require.resolve('./fixtures/sample.less')) - tokenizedBuffer = new TokenizedBuffer({ - buffer, - grammar: atom.grammars.grammarForScopeName('source.css.less'), - scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) - }) - }) - - it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe('// @color: #4D926F;') - }) - }) - - describe('css', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-css') - buffer = await TextBuffer.load(require.resolve('./fixtures/css.css')) - tokenizedBuffer = new TokenizedBuffer({ - buffer, - grammar: atom.grammars.grammarForScopeName('source.css'), - scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) - }) - }) - - it('comments/uncomments lines in the given range', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe('/*body {') - expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/') - expect(buffer.lineForRow(2)).toBe(' width: 110%;') - expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') - - tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(0)).toBe('/*body {') - expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/') - expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/') - expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') - - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe('body {') - expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;') - expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/') - expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') - }) - - it('uncomments lines with leading whitespace', () => { - buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/') - tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe(' width: 110%;') - }) - - it('uncomments lines with trailing whitespace', () => { - buffer.setTextInRange([[2, 0], [2, Infinity]], '/*width: 110%;*/ ') - tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe('width: 110%; ') - }) - - it('uncomments lines with leading and trailing whitespace', () => { - buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/ ') - tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe(' width: 110%; ') - }) - }) - - describe('coffeescript', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-coffee-script') - buffer = await TextBuffer.load(require.resolve('./fixtures/coffee.coffee')) - tokenizedBuffer = new TokenizedBuffer({ - buffer, - tabLength: 2, - grammar: atom.grammars.grammarForScopeName('source.coffee'), - scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) - }) - }) - - it('comments/uncomments lines in the given range', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 6) - expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()') - expect(buffer.lineForRow(5)).toBe(' # left = []') - expect(buffer.lineForRow(6)).toBe(' # right = []') - - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()') - expect(buffer.lineForRow(5)).toBe(' left = []') - expect(buffer.lineForRow(6)).toBe(' # right = []') - }) - - it('comments/uncomments empty lines', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7) - expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()') - expect(buffer.lineForRow(5)).toBe(' # left = []') - expect(buffer.lineForRow(6)).toBe(' # right = []') - expect(buffer.lineForRow(7)).toBe(' # ') - - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()') - expect(buffer.lineForRow(5)).toBe(' left = []') - expect(buffer.lineForRow(6)).toBe(' # right = []') - expect(buffer.lineForRow(7)).toBe(' # ') - }) - }) - - describe('javascript', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-javascript') - buffer = await TextBuffer.load(require.resolve('./fixtures/sample.js')) - tokenizedBuffer = new TokenizedBuffer({ - buffer, - tabLength: 2, - grammar: atom.grammars.grammarForScopeName('source.js'), - scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) - }) - }) - - it('comments/uncomments lines in the given range', () => { - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7) - expect(buffer.lineForRow(4)).toBe(' // while(items.length > 0) {') - expect(buffer.lineForRow(5)).toBe(' // current = items.shift();') - expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') - expect(buffer.lineForRow(7)).toBe(' // }') - - tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe(' while(items.length > 0) {') - expect(buffer.lineForRow(5)).toBe(' current = items.shift();') - expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') - expect(buffer.lineForRow(7)).toBe(' // }') - - buffer.setText('\tvar i;') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe('\t// var i;') - - buffer.setText('var i;') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe('// var i;') - - buffer.setText(' var i;') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe(' // var i;') - - buffer.setText(' ') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe(' // ') - - buffer.setText(' a\n \n b') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 2) - expect(buffer.lineForRow(0)).toBe(' // a') - expect(buffer.lineForRow(1)).toBe(' // ') - expect(buffer.lineForRow(2)).toBe(' // b') - - buffer.setText(' \n // var i;') - tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe(' ') - expect(buffer.lineForRow(1)).toBe(' var i;') - }) - }) - }) - describe('.isFoldableAtRow(row)', () => { beforeEach(() => { buffer = atom.project.bufferForPathSync('sample.js') diff --git a/spec/tooltip-manager-spec.coffee b/spec/tooltip-manager-spec.coffee deleted file mode 100644 index 95182853e..000000000 --- a/spec/tooltip-manager-spec.coffee +++ /dev/null @@ -1,213 +0,0 @@ -{CompositeDisposable} = require 'atom' -TooltipManager = require '../src/tooltip-manager' -Tooltip = require '../src/tooltip' -_ = require 'underscore-plus' - -describe "TooltipManager", -> - [manager, element] = [] - - ctrlX = _.humanizeKeystroke("ctrl-x") - ctrlY = _.humanizeKeystroke("ctrl-y") - - beforeEach -> - manager = new TooltipManager(keymapManager: atom.keymaps, viewRegistry: atom.views) - element = createElement 'foo' - - createElement = (className) -> - el = document.createElement('div') - el.classList.add(className) - jasmine.attachToDOM(el) - el - - mouseEnter = (element) -> - element.dispatchEvent(new CustomEvent('mouseenter', bubbles: false)) - element.dispatchEvent(new CustomEvent('mouseover', bubbles: true)) - - mouseLeave = (element) -> - element.dispatchEvent(new CustomEvent('mouseleave', bubbles: false)) - element.dispatchEvent(new CustomEvent('mouseout', bubbles: true)) - - hover = (element, fn) -> - mouseEnter(element) - advanceClock(manager.hoverDefaults.delay.show) - fn() - mouseLeave(element) - advanceClock(manager.hoverDefaults.delay.hide) - - describe "::add(target, options)", -> - describe "when the trigger is 'hover' (the default)", -> - it "creates a tooltip when hovering over the target element", -> - manager.add element, title: "Title" - hover element, -> - expect(document.body.querySelector(".tooltip")).toHaveText("Title") - - it "displays tooltips immediately when hovering over new elements once a tooltip has been displayed once", -> - disposables = new CompositeDisposable - element1 = createElement('foo') - disposables.add(manager.add element1, title: 'Title') - element2 = createElement('bar') - disposables.add(manager.add element2, title: 'Title') - element3 = createElement('baz') - disposables.add(manager.add element3, title: 'Title') - - hover element1, -> - expect(document.body.querySelector(".tooltip")).toBeNull() - - mouseEnter(element2) - expect(document.body.querySelector(".tooltip")).not.toBeNull() - mouseLeave(element2) - advanceClock(manager.hoverDefaults.delay.hide) - expect(document.body.querySelector(".tooltip")).toBeNull() - - advanceClock(Tooltip.FOLLOW_THROUGH_DURATION) - mouseEnter(element3) - expect(document.body.querySelector(".tooltip")).toBeNull() - advanceClock(manager.hoverDefaults.delay.show) - expect(document.body.querySelector(".tooltip")).not.toBeNull() - - disposables.dispose() - - describe "when the trigger is 'manual'", -> - it "creates a tooltip immediately and only hides it on dispose", -> - disposable = manager.add element, title: "Title", trigger: "manual" - expect(document.body.querySelector(".tooltip")).toHaveText("Title") - disposable.dispose() - expect(document.body.querySelector(".tooltip")).toBeNull() - - describe "when the trigger is 'click'", -> - it "shows and hides the tooltip when the target element is clicked", -> - disposable = manager.add element, title: "Title", trigger: "click" - expect(document.body.querySelector(".tooltip")).toBeNull() - element.click() - expect(document.body.querySelector(".tooltip")).not.toBeNull() - element.click() - expect(document.body.querySelector(".tooltip")).toBeNull() - - # Hide the tooltip when clicking anywhere but inside the tooltip element - element.click() - expect(document.body.querySelector(".tooltip")).not.toBeNull() - document.body.querySelector(".tooltip").click() - expect(document.body.querySelector(".tooltip")).not.toBeNull() - document.body.querySelector(".tooltip").firstChild.click() - expect(document.body.querySelector(".tooltip")).not.toBeNull() - document.body.click() - expect(document.body.querySelector(".tooltip")).toBeNull() - - # Tooltip can show again after hiding due to clicking outside of the tooltip - element.click() - expect(document.body.querySelector(".tooltip")).not.toBeNull() - element.click() - expect(document.body.querySelector(".tooltip")).toBeNull() - - it "allows a custom item to be specified for the content of the tooltip", -> - tooltipElement = document.createElement('div') - manager.add element, item: {element: tooltipElement} - hover element, -> - expect(tooltipElement.closest(".tooltip")).not.toBeNull() - - it "allows a custom class to be specified for the tooltip", -> - tooltipElement = document.createElement('div') - manager.add element, title: 'Title', class: 'custom-tooltip-class' - hover element, -> - expect(document.body.querySelector(".tooltip").classList.contains('custom-tooltip-class')).toBe(true) - - it "allows jQuery elements to be passed as the target", -> - element2 = document.createElement('div') - jasmine.attachToDOM(element2) - - fakeJqueryWrapper = [element, element2] - fakeJqueryWrapper.jquery = 'any-version' - disposable = manager.add fakeJqueryWrapper, title: "Title" - - hover element, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title") - expect(document.body.querySelector(".tooltip")).toBeNull() - hover element2, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title") - expect(document.body.querySelector(".tooltip")).toBeNull() - - disposable.dispose() - - hover element, -> expect(document.body.querySelector(".tooltip")).toBeNull() - hover element2, -> expect(document.body.querySelector(".tooltip")).toBeNull() - - describe "when a keyBindingCommand is specified", -> - describe "when a title is specified", -> - it "appends the key binding corresponding to the command to the title", -> - atom.keymaps.add 'test', - '.foo': 'ctrl-x ctrl-y': 'test-command' - '.bar': 'ctrl-x ctrl-z': 'test-command' - - manager.add element, title: "Title", keyBindingCommand: 'test-command' - - hover element, -> - tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "Title #{ctrlX} #{ctrlY}" - - describe "when no title is specified", -> - it "shows the key binding corresponding to the command alone", -> - atom.keymaps.add 'test', '.foo': 'ctrl-x ctrl-y': 'test-command' - - manager.add element, keyBindingCommand: 'test-command' - - hover element, -> - tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}" - - describe "when a keyBindingTarget is specified", -> - it "looks up the key binding relative to the target", -> - atom.keymaps.add 'test', - '.bar': 'ctrl-x ctrl-z': 'test-command' - '.foo': 'ctrl-x ctrl-y': 'test-command' - - manager.add element, keyBindingCommand: 'test-command', keyBindingTarget: element - - hover element, -> - tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}" - - it "does not display the keybinding if there is nothing mapped to the specified keyBindingCommand", -> - manager.add element, title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element - - hover element, -> - tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement.textContent).toBe "A Title" - - describe "when .dispose() is called on the returned disposable", -> - it "no longer displays the tooltip on hover", -> - disposable = manager.add element, title: "Title" - - hover element, -> - expect(document.body.querySelector(".tooltip")).toHaveText("Title") - - disposable.dispose() - - hover element, -> - expect(document.body.querySelector(".tooltip")).toBeNull() - - describe "when the window is resized", -> - it "hides the tooltips", -> - disposable = manager.add element, title: "Title" - hover element, -> - expect(document.body.querySelector(".tooltip")).not.toBeNull() - window.dispatchEvent(new CustomEvent('resize')) - expect(document.body.querySelector(".tooltip")).toBeNull() - disposable.dispose() - - describe "findTooltips", -> - it "adds and remove tooltips correctly", -> - expect(manager.findTooltips(element).length).toBe(0) - disposable1 = manager.add element, title: "elem1" - expect(manager.findTooltips(element).length).toBe(1) - disposable2 = manager.add element, title: "elem2" - expect(manager.findTooltips(element).length).toBe(2) - disposable1.dispose() - expect(manager.findTooltips(element).length).toBe(1) - disposable2.dispose() - expect(manager.findTooltips(element).length).toBe(0) - - it "lets us hide tooltips programmatically", -> - disposable = manager.add element, title: "Title" - hover element, -> - expect(document.body.querySelector(".tooltip")).not.toBeNull() - manager.findTooltips(element)[0].hide() - expect(document.body.querySelector(".tooltip")).toBeNull() - disposable.dispose() diff --git a/spec/tooltip-manager-spec.js b/spec/tooltip-manager-spec.js new file mode 100644 index 000000000..65587839f --- /dev/null +++ b/spec/tooltip-manager-spec.js @@ -0,0 +1,253 @@ +const {CompositeDisposable} = require('atom') +const TooltipManager = require('../src/tooltip-manager') +const Tooltip = require('../src/tooltip') +const _ = require('underscore-plus') + +describe('TooltipManager', () => { + let manager, element + + const ctrlX = _.humanizeKeystroke('ctrl-x') + const ctrlY = _.humanizeKeystroke('ctrl-y') + + const hover = function (element, fn) { + mouseEnter(element) + advanceClock(manager.hoverDefaults.delay.show) + fn() + mouseLeave(element) + advanceClock(manager.hoverDefaults.delay.hide) + } + + beforeEach(function () { + manager = new TooltipManager({keymapManager: atom.keymaps, viewRegistry: atom.views}) + element = createElement('foo') + }) + + describe('::add(target, options)', () => { + describe("when the trigger is 'hover' (the default)", () => { + it('creates a tooltip when hovering over the target element', () => { + manager.add(element, {title: 'Title'}) + hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title')) + }) + + it('displays tooltips immediately when hovering over new elements once a tooltip has been displayed once', () => { + const disposables = new CompositeDisposable() + const element1 = createElement('foo') + disposables.add(manager.add(element1, {title: 'Title'})) + const element2 = createElement('bar') + disposables.add(manager.add(element2, {title: 'Title'})) + const element3 = createElement('baz') + disposables.add(manager.add(element3, {title: 'Title'})) + + hover(element1, () => {}) + expect(document.body.querySelector('.tooltip')).toBeNull() + + mouseEnter(element2) + expect(document.body.querySelector('.tooltip')).not.toBeNull() + mouseLeave(element2) + advanceClock(manager.hoverDefaults.delay.hide) + expect(document.body.querySelector('.tooltip')).toBeNull() + + advanceClock(Tooltip.FOLLOW_THROUGH_DURATION) + mouseEnter(element3) + expect(document.body.querySelector('.tooltip')).toBeNull() + advanceClock(manager.hoverDefaults.delay.show) + expect(document.body.querySelector('.tooltip')).not.toBeNull() + + disposables.dispose() + }) + }) + + describe("when the trigger is 'manual'", () => + it('creates a tooltip immediately and only hides it on dispose', () => { + const disposable = manager.add(element, {title: 'Title', trigger: 'manual'}) + expect(document.body.querySelector('.tooltip')).toHaveText('Title') + disposable.dispose() + expect(document.body.querySelector('.tooltip')).toBeNull() + }) + ) + + describe("when the trigger is 'click'", () => + it('shows and hides the tooltip when the target element is clicked', () => { + manager.add(element, {title: 'Title', trigger: 'click'}) + expect(document.body.querySelector('.tooltip')).toBeNull() + element.click() + expect(document.body.querySelector('.tooltip')).not.toBeNull() + element.click() + expect(document.body.querySelector('.tooltip')).toBeNull() + + // Hide the tooltip when clicking anywhere but inside the tooltip element + element.click() + expect(document.body.querySelector('.tooltip')).not.toBeNull() + document.body.querySelector('.tooltip').click() + expect(document.body.querySelector('.tooltip')).not.toBeNull() + document.body.querySelector('.tooltip').firstChild.click() + expect(document.body.querySelector('.tooltip')).not.toBeNull() + document.body.click() + expect(document.body.querySelector('.tooltip')).toBeNull() + + // Tooltip can show again after hiding due to clicking outside of the tooltip + element.click() + expect(document.body.querySelector('.tooltip')).not.toBeNull() + element.click() + expect(document.body.querySelector('.tooltip')).toBeNull() + }) + ) + + it('allows a custom item to be specified for the content of the tooltip', () => { + const tooltipElement = document.createElement('div') + manager.add(element, {item: {element: tooltipElement}}) + hover(element, () => expect(tooltipElement.closest('.tooltip')).not.toBeNull()) + }) + + it('allows a custom class to be specified for the tooltip', () => { + manager.add(element, {title: 'Title', class: 'custom-tooltip-class'}) + hover(element, () => expect(document.body.querySelector('.tooltip').classList.contains('custom-tooltip-class')).toBe(true)) + }) + + it('allows jQuery elements to be passed as the target', () => { + const element2 = document.createElement('div') + jasmine.attachToDOM(element2) + + const fakeJqueryWrapper = [element, element2] + fakeJqueryWrapper.jquery = 'any-version' + const disposable = manager.add(fakeJqueryWrapper, {title: 'Title'}) + + hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title')) + expect(document.body.querySelector('.tooltip')).toBeNull() + hover(element2, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title')) + expect(document.body.querySelector('.tooltip')).toBeNull() + + disposable.dispose() + + hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull()) + hover(element2, () => expect(document.body.querySelector('.tooltip')).toBeNull()) + }) + + describe('when a keyBindingCommand is specified', () => { + describe('when a title is specified', () => + it('appends the key binding corresponding to the command to the title', () => { + atom.keymaps.add('test', { + '.foo': { 'ctrl-x ctrl-y': 'test-command' + }, + '.bar': { 'ctrl-x ctrl-z': 'test-command' + } + } + ) + + manager.add(element, {title: 'Title', keyBindingCommand: 'test-command'}) + + hover(element, function () { + const tooltipElement = document.body.querySelector('.tooltip') + expect(tooltipElement).toHaveText(`Title ${ctrlX} ${ctrlY}`) + }) + }) + ) + + describe('when no title is specified', () => + it('shows the key binding corresponding to the command alone', () => { + atom.keymaps.add('test', {'.foo': {'ctrl-x ctrl-y': 'test-command'}}) + + manager.add(element, {keyBindingCommand: 'test-command'}) + + hover(element, function () { + const tooltipElement = document.body.querySelector('.tooltip') + expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`) + }) + }) + ) + + describe('when a keyBindingTarget is specified', () => { + it('looks up the key binding relative to the target', () => { + atom.keymaps.add('test', { + '.bar': { 'ctrl-x ctrl-z': 'test-command' + }, + '.foo': { 'ctrl-x ctrl-y': 'test-command' + } + } + ) + + manager.add(element, {keyBindingCommand: 'test-command', keyBindingTarget: element}) + + hover(element, function () { + const tooltipElement = document.body.querySelector('.tooltip') + expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`) + }) + }) + + it('does not display the keybinding if there is nothing mapped to the specified keyBindingCommand', () => { + manager.add(element, {title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element}) + + hover(element, function () { + const tooltipElement = document.body.querySelector('.tooltip') + expect(tooltipElement.textContent).toBe('A Title') + }) + }) + }) + }) + + describe('when .dispose() is called on the returned disposable', () => + it('no longer displays the tooltip on hover', () => { + const disposable = manager.add(element, {title: 'Title'}) + + hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title')) + + disposable.dispose() + + hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull()) + }) + ) + + describe('when the window is resized', () => + it('hides the tooltips', () => { + const disposable = manager.add(element, {title: 'Title'}) + hover(element, function () { + expect(document.body.querySelector('.tooltip')).not.toBeNull() + window.dispatchEvent(new CustomEvent('resize')) + expect(document.body.querySelector('.tooltip')).toBeNull() + disposable.dispose() + }) + }) + ) + + describe('findTooltips', () => { + it('adds and remove tooltips correctly', () => { + expect(manager.findTooltips(element).length).toBe(0) + const disposable1 = manager.add(element, {title: 'elem1'}) + expect(manager.findTooltips(element).length).toBe(1) + const disposable2 = manager.add(element, {title: 'elem2'}) + expect(manager.findTooltips(element).length).toBe(2) + disposable1.dispose() + expect(manager.findTooltips(element).length).toBe(1) + disposable2.dispose() + expect(manager.findTooltips(element).length).toBe(0) + }) + + it('lets us hide tooltips programmatically', () => { + const disposable = manager.add(element, {title: 'Title'}) + hover(element, function () { + expect(document.body.querySelector('.tooltip')).not.toBeNull() + manager.findTooltips(element)[0].hide() + expect(document.body.querySelector('.tooltip')).toBeNull() + disposable.dispose() + }) + }) + }) + }) +}) + +function createElement (className) { + const el = document.createElement('div') + el.classList.add(className) + jasmine.attachToDOM(el) + return el +} + +function mouseEnter (element) { + element.dispatchEvent(new CustomEvent('mouseenter', {bubbles: false})) + element.dispatchEvent(new CustomEvent('mouseover', {bubbles: true})) +} + +function mouseLeave (element) { + element.dispatchEvent(new CustomEvent('mouseleave', {bubbles: false})) + element.dispatchEvent(new CustomEvent('mouseout', {bubbles: true})) +} diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee deleted file mode 100644 index 4bae1d811..000000000 --- a/spec/view-registry-spec.coffee +++ /dev/null @@ -1,163 +0,0 @@ -ViewRegistry = require '../src/view-registry' - -describe "ViewRegistry", -> - registry = null - - beforeEach -> - registry = new ViewRegistry - - afterEach -> - registry.clearDocumentRequests() - - describe "::getView(object)", -> - describe "when passed a DOM node", -> - it "returns the given DOM node", -> - node = document.createElement('div') - expect(registry.getView(node)).toBe node - - describe "when passed an object with an element property", -> - it "returns the element property if it's an instance of HTMLElement", -> - class TestComponent - constructor: -> @element = document.createElement('div') - - component = new TestComponent - expect(registry.getView(component)).toBe component.element - - describe "when passed an object with a getElement function", -> - it "returns the return value of getElement if it's an instance of HTMLElement", -> - class TestComponent - getElement: -> - @myElement ?= document.createElement('div') - - component = new TestComponent - expect(registry.getView(component)).toBe component.myElement - - describe "when passed a model object", -> - describe "when a view provider is registered matching the object's constructor", -> - it "constructs a view element and assigns the model on it", -> - class TestModel - - class TestModelSubclass extends TestModel - - class TestView - initialize: (@model) -> this - - model = new TestModel - - registry.addViewProvider TestModel, (model) -> - new TestView().initialize(model) - - view = registry.getView(model) - expect(view instanceof TestView).toBe true - expect(view.model).toBe model - - subclassModel = new TestModelSubclass - view2 = registry.getView(subclassModel) - expect(view2 instanceof TestView).toBe true - expect(view2.model).toBe subclassModel - - describe "when a view provider is registered generically, and works with the object", -> - it "constructs a view element and assigns the model on it", -> - model = {a: 'b'} - - registry.addViewProvider (model) -> - if model.a is 'b' - element = document.createElement('div') - element.className = 'test-element' - element - - view = registry.getView({a: 'b'}) - expect(view.className).toBe 'test-element' - - expect(-> registry.getView({a: 'c'})).toThrow() - - describe "when no view provider is registered for the object's constructor", -> - it "throws an exception", -> - expect(-> registry.getView(new Object)).toThrow() - - describe "::addViewProvider(providerSpec)", -> - it "returns a disposable that can be used to remove the provider", -> - class TestModel - class TestView - initialize: (@model) -> this - - disposable = registry.addViewProvider TestModel, (model) -> - new TestView().initialize(model) - - expect(registry.getView(new TestModel) instanceof TestView).toBe true - disposable.dispose() - expect(-> registry.getView(new TestModel)).toThrow() - - describe "::updateDocument(fn) and ::readDocument(fn)", -> - frameRequests = null - - beforeEach -> - frameRequests = [] - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> frameRequests.push(fn) - - it "performs all pending writes before all pending reads on the next animation frame", -> - events = [] - - registry.updateDocument -> events.push('write 1') - registry.readDocument -> events.push('read 1') - registry.readDocument -> events.push('read 2') - registry.updateDocument -> events.push('write 2') - - expect(events).toEqual [] - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(events).toEqual ['write 1', 'write 2', 'read 1', 'read 2'] - - frameRequests = [] - events = [] - disposable = registry.updateDocument -> events.push('write 3') - registry.updateDocument -> events.push('write 4') - registry.readDocument -> events.push('read 3') - - disposable.dispose() - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(events).toEqual ['write 4', 'read 3'] - - it "performs writes requested from read callbacks in the same animation frame", -> - spyOn(window, 'setInterval').andCallFake(fakeSetInterval) - spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) - events = [] - - registry.updateDocument -> events.push('write 1') - registry.readDocument -> - registry.updateDocument -> events.push('write from read 1') - events.push('read 1') - registry.readDocument -> - registry.updateDocument -> events.push('write from read 2') - events.push('read 2') - registry.updateDocument -> events.push('write 2') - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(frameRequests.length).toBe 1 - - expect(events).toEqual [ - 'write 1' - 'write 2' - 'read 1' - 'read 2' - 'write from read 1' - 'write from read 2' - ] - - describe "::getNextUpdatePromise()", -> - it "returns a promise that resolves at the end of the next update cycle", -> - updateCalled = false - readCalled = false - - waitsFor 'getNextUpdatePromise to resolve', (done) -> - registry.getNextUpdatePromise().then -> - expect(updateCalled).toBe true - expect(readCalled).toBe true - done() - - registry.updateDocument -> updateCalled = true - registry.readDocument -> readCalled = true diff --git a/spec/view-registry-spec.js b/spec/view-registry-spec.js new file mode 100644 index 000000000..db8b077f1 --- /dev/null +++ b/spec/view-registry-spec.js @@ -0,0 +1,216 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const ViewRegistry = require('../src/view-registry') + +describe('ViewRegistry', () => { + let registry = null + + beforeEach(() => { + registry = new ViewRegistry() + }) + + afterEach(() => { + registry.clearDocumentRequests() + }) + + describe('::getView(object)', () => { + describe('when passed a DOM node', () => + it('returns the given DOM node', () => { + const node = document.createElement('div') + expect(registry.getView(node)).toBe(node) + }) + ) + + describe('when passed an object with an element property', () => + it("returns the element property if it's an instance of HTMLElement", () => { + class TestComponent { + constructor () { + this.element = document.createElement('div') + } + } + + const component = new TestComponent() + expect(registry.getView(component)).toBe(component.element) + }) + ) + + describe('when passed an object with a getElement function', () => + it("returns the return value of getElement if it's an instance of HTMLElement", () => { + class TestComponent { + getElement () { + if (this.myElement == null) { + this.myElement = document.createElement('div') + } + return this.myElement + } + } + + const component = new TestComponent() + expect(registry.getView(component)).toBe(component.myElement) + }) + ) + + describe('when passed a model object', () => { + describe("when a view provider is registered matching the object's constructor", () => + it('constructs a view element and assigns the model on it', () => { + class TestModel {} + + class TestModelSubclass extends TestModel {} + + class TestView { + initialize (model) { + this.model = model + return this + } + } + + const model = new TestModel() + + registry.addViewProvider(TestModel, (model) => + new TestView().initialize(model) + ) + + const view = registry.getView(model) + expect(view instanceof TestView).toBe(true) + expect(view.model).toBe(model) + + const subclassModel = new TestModelSubclass() + const view2 = registry.getView(subclassModel) + expect(view2 instanceof TestView).toBe(true) + expect(view2.model).toBe(subclassModel) + }) + ) + + describe('when a view provider is registered generically, and works with the object', () => + it('constructs a view element and assigns the model on it', () => { + registry.addViewProvider((model) => { + if (model.a === 'b') { + const element = document.createElement('div') + element.className = 'test-element' + return element + } + }) + + const view = registry.getView({a: 'b'}) + expect(view.className).toBe('test-element') + + expect(() => registry.getView({a: 'c'})).toThrow() + }) + ) + + describe("when no view provider is registered for the object's constructor", () => + it('throws an exception', () => { + expect(() => registry.getView({})).toThrow() + }) + ) + }) + }) + + describe('::addViewProvider(providerSpec)', () => + it('returns a disposable that can be used to remove the provider', () => { + class TestModel {} + class TestView { + initialize (model) { + this.model = model + return this + } + } + + const disposable = registry.addViewProvider(TestModel, (model) => + new TestView().initialize(model) + ) + + expect(registry.getView(new TestModel()) instanceof TestView).toBe(true) + disposable.dispose() + expect(() => registry.getView(new TestModel())).toThrow() + }) + ) + + describe('::updateDocument(fn) and ::readDocument(fn)', () => { + let frameRequests = null + + beforeEach(() => { + frameRequests = [] + spyOn(window, 'requestAnimationFrame').andCallFake(fn => frameRequests.push(fn)) + }) + + it('performs all pending writes before all pending reads on the next animation frame', () => { + let events = [] + + registry.updateDocument(() => events.push('write 1')) + registry.readDocument(() => events.push('read 1')) + registry.readDocument(() => events.push('read 2')) + registry.updateDocument(() => events.push('write 2')) + + expect(events).toEqual([]) + + expect(frameRequests.length).toBe(1) + frameRequests[0]() + expect(events).toEqual(['write 1', 'write 2', 'read 1', 'read 2']) + + frameRequests = [] + events = [] + const disposable = registry.updateDocument(() => events.push('write 3')) + registry.updateDocument(() => events.push('write 4')) + registry.readDocument(() => events.push('read 3')) + + disposable.dispose() + + expect(frameRequests.length).toBe(1) + frameRequests[0]() + expect(events).toEqual(['write 4', 'read 3']) + }) + + it('performs writes requested from read callbacks in the same animation frame', () => { + spyOn(window, 'setInterval').andCallFake(fakeSetInterval) + spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) + const events = [] + + registry.updateDocument(() => events.push('write 1')) + registry.readDocument(() => { + registry.updateDocument(() => events.push('write from read 1')) + events.push('read 1') + }) + registry.readDocument(() => { + registry.updateDocument(() => events.push('write from read 2')) + events.push('read 2') + }) + registry.updateDocument(() => events.push('write 2')) + + expect(frameRequests.length).toBe(1) + frameRequests[0]() + expect(frameRequests.length).toBe(1) + + expect(events).toEqual([ + 'write 1', + 'write 2', + 'read 1', + 'read 2', + 'write from read 1', + 'write from read 2' + ]) + }) + }) + + describe('::getNextUpdatePromise()', () => + it('returns a promise that resolves at the end of the next update cycle', () => { + let updateCalled = false + let readCalled = false + + waitsFor('getNextUpdatePromise to resolve', (done) => { + registry.getNextUpdatePromise().then(() => { + expect(updateCalled).toBe(true) + expect(readCalled).toBe(true) + done() + }) + + registry.updateDocument(() => { updateCalled = true }) + registry.readDocument(() => { readCalled = true }) + }) + }) + ) +}) diff --git a/spec/window-event-handler-spec.coffee b/spec/window-event-handler-spec.coffee deleted file mode 100644 index 9c9f4a098..000000000 --- a/spec/window-event-handler-spec.coffee +++ /dev/null @@ -1,209 +0,0 @@ -KeymapManager = require 'atom-keymap' -TextEditor = require '../src/text-editor' -WindowEventHandler = require '../src/window-event-handler' -{ipcRenderer} = require 'electron' - -describe "WindowEventHandler", -> - [windowEventHandler] = [] - - beforeEach -> - atom.uninstallWindowEventHandler() - spyOn(atom, 'hide') - initialPath = atom.project.getPaths()[0] - spyOn(atom, 'getLoadSettings').andCallFake -> - loadSettings = atom.getLoadSettings.originalValue.call(atom) - loadSettings.initialPath = initialPath - loadSettings - atom.project.destroy() - windowEventHandler = new WindowEventHandler({atomEnvironment: atom, applicationDelegate: atom.applicationDelegate}) - windowEventHandler.initialize(window, document) - - afterEach -> - windowEventHandler.unsubscribe() - atom.installWindowEventHandler() - - describe "when the window is loaded", -> - it "doesn't have .is-blurred on the body tag", -> - return if process.platform is 'win32' #Win32TestFailures - can not steal focus - expect(document.body.className).not.toMatch("is-blurred") - - describe "when the window is blurred", -> - beforeEach -> - window.dispatchEvent(new CustomEvent('blur')) - - afterEach -> - document.body.classList.remove('is-blurred') - - it "adds the .is-blurred class on the body", -> - expect(document.body.className).toMatch("is-blurred") - - describe "when the window is focused again", -> - it "removes the .is-blurred class from the body", -> - window.dispatchEvent(new CustomEvent('focus')) - expect(document.body.className).not.toMatch("is-blurred") - - describe "window:close event", -> - it "closes the window", -> - spyOn(atom, 'close') - window.dispatchEvent(new CustomEvent('window:close')) - expect(atom.close).toHaveBeenCalled() - - describe "when a link is clicked", -> - it "opens the http/https links in an external application", -> - {shell} = require 'electron' - spyOn(shell, 'openExternal') - - link = document.createElement('a') - linkChild = document.createElement('span') - link.appendChild(linkChild) - link.href = 'http://github.com' - jasmine.attachToDOM(link) - fakeEvent = {target: linkChild, currentTarget: link, preventDefault: (->)} - - windowEventHandler.handleLinkClick(fakeEvent) - expect(shell.openExternal).toHaveBeenCalled() - expect(shell.openExternal.argsForCall[0][0]).toBe "http://github.com" - shell.openExternal.reset() - - link.href = 'https://github.com' - windowEventHandler.handleLinkClick(fakeEvent) - expect(shell.openExternal).toHaveBeenCalled() - expect(shell.openExternal.argsForCall[0][0]).toBe "https://github.com" - shell.openExternal.reset() - - link.href = '' - windowEventHandler.handleLinkClick(fakeEvent) - expect(shell.openExternal).not.toHaveBeenCalled() - shell.openExternal.reset() - - link.href = '#scroll-me' - windowEventHandler.handleLinkClick(fakeEvent) - expect(shell.openExternal).not.toHaveBeenCalled() - - describe "when a form is submitted", -> - it "prevents the default so that the window's URL isn't changed", -> - form = document.createElement('form') - jasmine.attachToDOM(form) - - defaultPrevented = false - event = new CustomEvent('submit', bubbles: true) - event.preventDefault = -> defaultPrevented = true - form.dispatchEvent(event) - expect(defaultPrevented).toBe(true) - - describe "core:focus-next and core:focus-previous", -> - describe "when there is no currently focused element", -> - it "focuses the element with the lowest/highest tabindex", -> - wrapperDiv = document.createElement('div') - wrapperDiv.innerHTML = """ -
- - -
- """ - elements = wrapperDiv.firstChild - jasmine.attachToDOM(elements) - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 1 - - document.body.focus() - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 2 - - describe "when a tabindex is set on the currently focused element", -> - it "focuses the element with the next highest/lowest tabindex, skipping disabled elements", -> - wrapperDiv = document.createElement('div') - wrapperDiv.innerHTML = """ -
- - - - - - - -
- """ - elements = wrapperDiv.firstChild - jasmine.attachToDOM(elements) - - elements.querySelector('[tabindex="1"]').focus() - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 2 - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 3 - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 5 - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 7 - - elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 1 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 7 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 5 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 3 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 2 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 1 - - elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true)) - expect(document.activeElement.tabIndex).toBe 7 - - describe "when keydown events occur on the document", -> - it "dispatches the event via the KeymapManager and CommandRegistry", -> - dispatchedCommands = [] - atom.commands.onWillDispatch (command) -> dispatchedCommands.push(command) - atom.commands.add '*', 'foo-command': -> - atom.keymaps.add 'source-name', '*': {'x': 'foo-command'} - - event = KeymapManager.buildKeydownEvent('x', target: document.createElement('div')) - document.dispatchEvent(event) - - expect(dispatchedCommands.length).toBe 1 - expect(dispatchedCommands[0].type).toBe 'foo-command' - - describe "native key bindings", -> - it "correctly dispatches them to active elements with the '.native-key-bindings' class", -> - webContentsSpy = jasmine.createSpyObj("webContents", ["copy", "paste"]) - spyOn(atom.applicationDelegate, "getCurrentWindow").andReturn({ - webContents: webContentsSpy - on: -> - }) - - nativeKeyBindingsInput = document.createElement("input") - nativeKeyBindingsInput.classList.add("native-key-bindings") - jasmine.attachToDOM(nativeKeyBindingsInput) - nativeKeyBindingsInput.focus() - - atom.dispatchApplicationMenuCommand("core:copy") - atom.dispatchApplicationMenuCommand("core:paste") - - expect(webContentsSpy.copy).toHaveBeenCalled() - expect(webContentsSpy.paste).toHaveBeenCalled() - - webContentsSpy.copy.reset() - webContentsSpy.paste.reset() - - normalInput = document.createElement("input") - jasmine.attachToDOM(normalInput) - normalInput.focus() - - atom.dispatchApplicationMenuCommand("core:copy") - atom.dispatchApplicationMenuCommand("core:paste") - - expect(webContentsSpy.copy).not.toHaveBeenCalled() - expect(webContentsSpy.paste).not.toHaveBeenCalled() diff --git a/spec/window-event-handler-spec.js b/spec/window-event-handler-spec.js new file mode 100644 index 000000000..a03e168fa --- /dev/null +++ b/spec/window-event-handler-spec.js @@ -0,0 +1,228 @@ +const KeymapManager = require('atom-keymap') +const WindowEventHandler = require('../src/window-event-handler') + +describe('WindowEventHandler', () => { + let windowEventHandler + + beforeEach(() => { + atom.uninstallWindowEventHandler() + spyOn(atom, 'hide') + const initialPath = atom.project.getPaths()[0] + spyOn(atom, 'getLoadSettings').andCallFake(() => { + const loadSettings = atom.getLoadSettings.originalValue.call(atom) + loadSettings.initialPath = initialPath + return loadSettings + }) + atom.project.destroy() + windowEventHandler = new WindowEventHandler({atomEnvironment: atom, applicationDelegate: atom.applicationDelegate}) + windowEventHandler.initialize(window, document) + }) + + afterEach(() => { + windowEventHandler.unsubscribe() + atom.installWindowEventHandler() + }) + + describe('when the window is loaded', () => + it("doesn't have .is-blurred on the body tag", () => { + if (process.platform === 'win32') { return } // Win32TestFailures - can not steal focus + expect(document.body.className).not.toMatch('is-blurred') + }) + ) + + describe('when the window is blurred', () => { + beforeEach(() => window.dispatchEvent(new CustomEvent('blur'))) + + afterEach(() => document.body.classList.remove('is-blurred')) + + it('adds the .is-blurred class on the body', () => expect(document.body.className).toMatch('is-blurred')) + + describe('when the window is focused again', () => + it('removes the .is-blurred class from the body', () => { + window.dispatchEvent(new CustomEvent('focus')) + expect(document.body.className).not.toMatch('is-blurred') + }) + ) + }) + + describe('window:close event', () => + it('closes the window', () => { + spyOn(atom, 'close') + window.dispatchEvent(new CustomEvent('window:close')) + expect(atom.close).toHaveBeenCalled() + }) + ) + + describe('when a link is clicked', () => + it('opens the http/https links in an external application', () => { + const {shell} = require('electron') + spyOn(shell, 'openExternal') + + const link = document.createElement('a') + const linkChild = document.createElement('span') + link.appendChild(linkChild) + link.href = 'http://github.com' + jasmine.attachToDOM(link) + const fakeEvent = {target: linkChild, currentTarget: link, preventDefault: () => {}} + + windowEventHandler.handleLinkClick(fakeEvent) + expect(shell.openExternal).toHaveBeenCalled() + expect(shell.openExternal.argsForCall[0][0]).toBe('http://github.com') + shell.openExternal.reset() + + link.href = 'https://github.com' + windowEventHandler.handleLinkClick(fakeEvent) + expect(shell.openExternal).toHaveBeenCalled() + expect(shell.openExternal.argsForCall[0][0]).toBe('https://github.com') + shell.openExternal.reset() + + link.href = '' + windowEventHandler.handleLinkClick(fakeEvent) + expect(shell.openExternal).not.toHaveBeenCalled() + shell.openExternal.reset() + + link.href = '#scroll-me' + windowEventHandler.handleLinkClick(fakeEvent) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + ) + + describe('when a form is submitted', () => + it("prevents the default so that the window's URL isn't changed", () => { + const form = document.createElement('form') + jasmine.attachToDOM(form) + + let defaultPrevented = false + const event = new CustomEvent('submit', {bubbles: true}) + event.preventDefault = () => { defaultPrevented = true } + form.dispatchEvent(event) + expect(defaultPrevented).toBe(true) + }) + ) + + describe('core:focus-next and core:focus-previous', () => { + describe('when there is no currently focused element', () => + it('focuses the element with the lowest/highest tabindex', () => { + const wrapperDiv = document.createElement('div') + wrapperDiv.innerHTML = ` +
+ + +
+ `.trim() + const elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + document.body.focus() + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + }) + ) + + describe('when a tabindex is set on the currently focused element', () => + it('focuses the element with the next highest/lowest tabindex, skipping disabled elements', () => { + const wrapperDiv = document.createElement('div') + wrapperDiv.innerHTML = ` +
+ + + + + + + +
+ `.trim() + const elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) + + elements.querySelector('[tabindex="1"]').focus() + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(3) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(5) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(5) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(3) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + }) + ) + }) + + describe('when keydown events occur on the document', () => + it('dispatches the event via the KeymapManager and CommandRegistry', () => { + const dispatchedCommands = [] + atom.commands.onWillDispatch(command => dispatchedCommands.push(command)) + atom.commands.add('*', {'foo-command': () => {}}) + atom.keymaps.add('source-name', {'*': {'x': 'foo-command'}}) + + const event = KeymapManager.buildKeydownEvent('x', {target: document.createElement('div')}) + document.dispatchEvent(event) + + expect(dispatchedCommands.length).toBe(1) + expect(dispatchedCommands[0].type).toBe('foo-command') + }) + ) + + describe('native key bindings', () => + it("correctly dispatches them to active elements with the '.native-key-bindings' class", () => { + const webContentsSpy = jasmine.createSpyObj('webContents', ['copy', 'paste']) + spyOn(atom.applicationDelegate, 'getCurrentWindow').andReturn({ + webContents: webContentsSpy, + on: () => {} + }) + + const nativeKeyBindingsInput = document.createElement('input') + nativeKeyBindingsInput.classList.add('native-key-bindings') + jasmine.attachToDOM(nativeKeyBindingsInput) + nativeKeyBindingsInput.focus() + + atom.dispatchApplicationMenuCommand('core:copy') + atom.dispatchApplicationMenuCommand('core:paste') + + expect(webContentsSpy.copy).toHaveBeenCalled() + expect(webContentsSpy.paste).toHaveBeenCalled() + + webContentsSpy.copy.reset() + webContentsSpy.paste.reset() + + const normalInput = document.createElement('input') + jasmine.attachToDOM(normalInput) + normalInput.focus() + + atom.dispatchApplicationMenuCommand('core:copy') + atom.dispatchApplicationMenuCommand('core:paste') + + expect(webContentsSpy.copy).not.toHaveBeenCalled() + expect(webContentsSpy.paste).not.toHaveBeenCalled() + }) + ) +}) diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index 43a04eba9..1bde0e6fe 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -1585,15 +1585,15 @@ i = /test/; #FIXME\ atom2.project.deserialize(atom.project.serialize()) atom2.workspace.deserialize(atom.workspace.serialize(), atom2.deserializers) - expect(atom2.grammars.getGrammars().map(grammar => grammar.name).sort()).toEqual([ - 'CoffeeScript', - 'CoffeeScript (Literate)', - 'JSDoc', - 'JavaScript', - 'Null Grammar', - 'Regular Expression Replacement (JavaScript)', - 'Regular Expressions (JavaScript)', - 'TODO' + expect(atom2.grammars.getGrammars().map(grammar => grammar.scopeName).sort()).toEqual([ + 'source.coffee', + 'source.js', + 'source.js.regexp', + 'source.js.regexp.replacement', + 'source.jsdoc', + 'source.litcoffee', + 'text.plain.null-grammar', + 'text.todo' ]) atom2.destroy() diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index b9c6306ab..50b5d541e 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -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' diff --git a/src/command-registry.js b/src/command-registry.js index 30089b7f1..9e6d8c2e1 100644 --- a/src/command-registry.js +++ b/src/command-registry.js @@ -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 // diff --git a/src/cursor.js b/src/cursor.js index 6cd0cc623..10bdef804 100644 --- a/src/cursor.js +++ b/src/cursor.js @@ -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 => diff --git a/src/grammar-registry.js b/src/grammar-registry.js index b1de16ba1..f2994acf1 100644 --- a/src/grammar-registry.js +++ b/src/grammar-registry.js @@ -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 } diff --git a/src/path-watcher.js b/src/path-watcher.js index 2dfece46e..5a2d10bde 100644 --- a/src/path-watcher.js +++ b/src/path-watcher.js @@ -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. diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index d5b741c40..7dc0d3298 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -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() diff --git a/src/selection.coffee b/src/selection.coffee deleted file mode 100644 index 4d3fe8882..000000000 --- a/src/selection.coffee +++ /dev/null @@ -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() diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 000000000..a54ba68b8 --- /dev/null +++ b/src/selection.js @@ -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() + } +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5ff96eec5..91ea18361 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -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 () { diff --git a/src/text-editor-registry.js b/src/text-editor-registry.js index 2cbf3093c..d891a5868 100644 --- a/src/text-editor-registry.js +++ b/src/text-editor-registry.js @@ -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) } } diff --git a/src/text-editor.coffee b/src/text-editor.coffee deleted file mode 100644 index c00508f09..000000000 --- a/src/text-editor.coffee +++ /dev/null @@ -1,3909 +0,0 @@ -_ = require 'underscore-plus' -path = require 'path' -fs = require 'fs-plus' -Grim = require 'grim' -{CompositeDisposable, Disposable, Emitter} = require 'event-kit' -{Point, Range} = TextBuffer = require 'text-buffer' -DecorationManager = require './decoration-manager' -TokenizedBuffer = require './tokenized-buffer' -Cursor = require './cursor' -Model = require './model' -Selection = require './selection' -TextMateScopeSelector = require('first-mate').ScopeSelector -GutterContainer = require './gutter-container' -TextEditorComponent = null -TextEditorElement = null -{isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' - -NON_WHITESPACE_REGEXP = /\S/ -ZERO_WIDTH_NBSP = '\ufeff' - -# Essential: This class represents all essential editing state for a single -# {TextBuffer}, including cursor and selection positions, folds, and soft wraps. -# If you're manipulating the state of an editor, use this class. -# -# A single {TextBuffer} can belong to multiple editors. For example, if the -# same file is open in two different panes, Atom creates a separate editor for -# each pane. If the buffer is manipulated the changes are reflected in both -# editors, but each maintains its own cursor position, folded lines, etc. -# -# ## Accessing TextEditor Instances -# -# The easiest way to get hold of `TextEditor` objects is by registering a callback -# with `::observeTextEditors` on the `atom.workspace` global. Your callback will -# then be called with all current editor instances and also when any editor is -# created in the future. -# -# ```coffee -# atom.workspace.observeTextEditors (editor) -> -# editor.insertText('Hello World') -# ``` -# -# ## Buffer vs. Screen Coordinates -# -# Because editors support folds and soft-wrapping, the lines on screen don't -# always match the lines in the buffer. For example, a long line that soft wraps -# twice renders as three lines on screen, but only represents one line in the -# buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds -# to row 11 in the buffer. -# -# Your choice of coordinates systems will depend on what you're trying to -# achieve. For example, if you're writing a command that jumps the cursor up or -# down by 10 lines, you'll want to use screen coordinates because the user -# probably wants to skip lines *on screen*. However, if you're writing a package -# that jumps between method definitions, you'll want to work in buffer -# coordinates. -# -# **When in doubt, just default to buffer coordinates**, then experiment with -# soft wraps and folds to ensure your code interacts with them correctly. -module.exports = -class TextEditor extends Model - @setClipboard: (clipboard) -> - @clipboard = clipboard - - @setScheduler: (scheduler) -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.setScheduler(scheduler) - - @didUpdateStyles: -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.didUpdateStyles() - - @didUpdateScrollbarStyles: -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.didUpdateScrollbarStyles() - - @viewForItem: (item) -> item.element ? item - - serializationVersion: 1 - - buffer: null - cursors: null - showCursorOnSelection: null - selections: null - suppressSelectionMerging: false - selectionFlashDuration: 500 - gutterContainer: null - editorElement: null - verticalScrollMargin: 2 - horizontalScrollMargin: 6 - softWrapped: null - editorWidthInChars: null - lineHeightInPixels: null - defaultCharWidth: null - height: null - width: null - registered: false - atomicSoftTabs: true - invisibles: null - - Object.defineProperty @prototype, "element", - get: -> @getElement() - - Object.defineProperty @prototype, "editorElement", - get: -> - Grim.deprecate(""" - `TextEditor.prototype.editorElement` has always been private, but now - it is gone. Reading the `editorElement` property still returns a - reference to the editor element but this field will be removed in a - later version of Atom, so we recommend using the `element` property instead. - """) - - @getElement() - - Object.defineProperty(@prototype, 'displayBuffer', get: -> - Grim.deprecate(""" - `TextEditor.prototype.displayBuffer` has always been private, but now - it is gone. Reading the `displayBuffer` property now returns a reference - to the containing `TextEditor`, which now provides *some* of the API of - the defunct `DisplayBuffer` class. - """) - this - ) - - Object.defineProperty(@prototype, 'languageMode', get: -> @tokenizedBuffer) - - @deserialize: (state, atomEnvironment) -> - # TODO: Return null on version mismatch when 1.8.0 has been out for a while - if state.version isnt @prototype.serializationVersion and state.displayBuffer? - state.tokenizedBuffer = state.displayBuffer.tokenizedBuffer - - try - tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) - return null unless tokenizedBuffer? - - state.tokenizedBuffer = tokenizedBuffer - state.tabLength = state.tokenizedBuffer.getTabLength() - catch error - if error.syscall is 'read' - return # Error reading the file, don't deserialize an editor for it - else - throw error - - state.buffer = state.tokenizedBuffer.buffer - state.assert = atomEnvironment.assert.bind(atomEnvironment) - editor = new this(state) - if state.registered - disposable = atomEnvironment.textEditors.add(editor) - editor.onDidDestroy -> disposable.dispose() - editor - - constructor: (params={}) -> - unless @constructor.clipboard? - throw new Error("Must call TextEditor.setClipboard at least once before creating TextEditor instances") - - super - - { - @softTabs, @initialScrollTopRow, @initialScrollLeftColumn, initialLine, initialColumn, tabLength, - @decorationManager, @selectionsMarkerLayer, @buffer, suppressCursorCreation, - @mini, @placeholderText, lineNumberGutterVisible, @showLineNumbers, @largeFileMode, - @assert, grammar, @showInvisibles, @autoHeight, @autoWidth, @scrollPastEnd, @scrollSensitivity, @editorWidthInChars, - @tokenizedBuffer, @displayLayer, @invisibles, @showIndentGuide, - @softWrapped, @softWrapAtPreferredLineLength, @preferredLineLength, - @showCursorOnSelection, @maxScreenLineLength - } = params - - @assert ?= (condition) -> condition - @emitter = new Emitter - @disposables = new CompositeDisposable - @cursors = [] - @cursorsByMarkerId = new Map - @selections = [] - @hasTerminatedPendingState = false - - @mini ?= false - @scrollPastEnd ?= false - @scrollSensitivity ?= 40 - @showInvisibles ?= true - @softTabs ?= true - tabLength ?= 2 - @autoIndent ?= true - @autoIndentOnPaste ?= true - @showCursorOnSelection ?= true - @undoGroupingInterval ?= 300 - @nonWordCharacters ?= "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" - @softWrapped ?= false - @softWrapAtPreferredLineLength ?= false - @preferredLineLength ?= 80 - @maxScreenLineLength ?= 500 - @showLineNumbers ?= true - - @buffer ?= new TextBuffer({ - shouldDestroyOnFileDelete: -> atom.config.get('core.closeDeletedFileTabs') - }) - @tokenizedBuffer ?= new TokenizedBuffer({ - grammar, tabLength, @buffer, @largeFileMode, @assert - }) - - unless @displayLayer? - displayLayerParams = { - invisibles: @getInvisibles(), - softWrapColumn: @getSoftWrapColumn(), - showIndentGuides: @doesShowIndentGuide(), - atomicSoftTabs: params.atomicSoftTabs ? true, - tabLength: tabLength, - ratioForCharacter: @ratioForCharacter.bind(this), - isWrapBoundary: isWrapBoundary, - foldCharacter: ZERO_WIDTH_NBSP, - softWrapHangingIndent: params.softWrapHangingIndentLength ? 0 - } - - if @displayLayer = @buffer.getDisplayLayer(params.displayLayerId) - @displayLayer.reset(displayLayerParams) - @selectionsMarkerLayer = @displayLayer.getMarkerLayer(params.selectionsMarkerLayerId) - else - @displayLayer = @buffer.addDisplayLayer(displayLayerParams) - - @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) - @disposables.add new Disposable => - cancelIdleCallback(@backgroundWorkHandle) if @backgroundWorkHandle? - - @displayLayer.setTextDecorationLayer(@tokenizedBuffer) - @defaultMarkerLayer = @displayLayer.addMarkerLayer() - @disposables.add(@defaultMarkerLayer.onDidDestroy => - @assert(false, "defaultMarkerLayer destroyed at an unexpected time") - ) - @selectionsMarkerLayer ?= @addMarkerLayer(maintainHistory: true, persistent: true) - @selectionsMarkerLayer.trackDestructionInOnDidCreateMarkerCallbacks = true - - @decorationManager = new DecorationManager(this) - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'cursor') - @decorateCursorLine() unless @isMini() - - @decorateMarkerLayer(@displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) - - for marker in @selectionsMarkerLayer.getMarkers() - @addSelection(marker) - - @subscribeToBuffer() - @subscribeToDisplayLayer() - - if @cursors.length is 0 and not suppressCursorCreation - initialLine = Math.max(parseInt(initialLine) or 0, 0) - initialColumn = Math.max(parseInt(initialColumn) or 0, 0) - @addCursorAtBufferPosition([initialLine, initialColumn]) - - @gutterContainer = new GutterContainer(this) - @lineNumberGutter = @gutterContainer.addGutter - name: 'line-number' - priority: 0 - visible: lineNumberGutterVisible - - decorateCursorLine: -> - @cursorLineDecorations = [ - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line', class: 'cursor-line', onlyEmpty: true), - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line'), - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) - ] - - doBackgroundWork: (deadline) => - previousLongestRow = @getApproximateLongestScreenRow() - if @displayLayer.doBackgroundWork(deadline) - @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) - else - @backgroundWorkHandle = null - - if @getApproximateLongestScreenRow() isnt previousLongestRow - @component?.scheduleUpdate() - - update: (params) -> - displayLayerParams = {} - - for param in Object.keys(params) - value = params[param] - - switch param - when 'autoIndent' - @autoIndent = value - - when 'autoIndentOnPaste' - @autoIndentOnPaste = value - - when 'undoGroupingInterval' - @undoGroupingInterval = value - - when 'nonWordCharacters' - @nonWordCharacters = value - - when 'scrollSensitivity' - @scrollSensitivity = value - - when 'encoding' - @buffer.setEncoding(value) - - when 'softTabs' - if value isnt @softTabs - @softTabs = value - - when 'atomicSoftTabs' - if value isnt @displayLayer.atomicSoftTabs - displayLayerParams.atomicSoftTabs = value - - when 'tabLength' - if value? and value isnt @tokenizedBuffer.getTabLength() - @tokenizedBuffer.setTabLength(value) - displayLayerParams.tabLength = value - - when 'softWrapped' - if value isnt @softWrapped - @softWrapped = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - @emitter.emit 'did-change-soft-wrapped', @isSoftWrapped() - - when 'softWrapHangingIndentLength' - if value isnt @displayLayer.softWrapHangingIndent - displayLayerParams.softWrapHangingIndent = value - - when 'softWrapAtPreferredLineLength' - if value isnt @softWrapAtPreferredLineLength - @softWrapAtPreferredLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'preferredLineLength' - if value isnt @preferredLineLength - @preferredLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'maxScreenLineLength' - if value isnt @maxScreenLineLength - @maxScreenLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'mini' - if value isnt @mini - @mini = value - @emitter.emit 'did-change-mini', value - displayLayerParams.invisibles = @getInvisibles() - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - displayLayerParams.showIndentGuides = @doesShowIndentGuide() - if @mini - decoration.destroy() for decoration in @cursorLineDecorations - @cursorLineDecorations = null - else - @decorateCursorLine() - @component?.scheduleUpdate() - - when 'placeholderText' - if value isnt @placeholderText - @placeholderText = value - @emitter.emit 'did-change-placeholder-text', value - - when 'lineNumberGutterVisible' - if value isnt @lineNumberGutterVisible - if value - @lineNumberGutter.show() - else - @lineNumberGutter.hide() - @emitter.emit 'did-change-line-number-gutter-visible', @lineNumberGutter.isVisible() - - when 'showIndentGuide' - if value isnt @showIndentGuide - @showIndentGuide = value - displayLayerParams.showIndentGuides = @doesShowIndentGuide() - - when 'showLineNumbers' - if value isnt @showLineNumbers - @showLineNumbers = value - @component?.scheduleUpdate() - - when 'showInvisibles' - if value isnt @showInvisibles - @showInvisibles = value - displayLayerParams.invisibles = @getInvisibles() - - when 'invisibles' - if not _.isEqual(value, @invisibles) - @invisibles = value - displayLayerParams.invisibles = @getInvisibles() - - when 'editorWidthInChars' - if value > 0 and value isnt @editorWidthInChars - @editorWidthInChars = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'width' - if value isnt @width - @width = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'scrollPastEnd' - if value isnt @scrollPastEnd - @scrollPastEnd = value - @component?.scheduleUpdate() - - when 'autoHeight' - if value isnt @autoHeight - @autoHeight = value - - when 'autoWidth' - if value isnt @autoWidth - @autoWidth = value - - when 'showCursorOnSelection' - if value isnt @showCursorOnSelection - @showCursorOnSelection = value - @component?.scheduleUpdate() - - else - if param isnt 'ref' and param isnt 'key' - throw new TypeError("Invalid TextEditor parameter: '#{param}'") - - @displayLayer.reset(displayLayerParams) - - if @component? - @component.getNextUpdatePromise() - else - Promise.resolve() - - scheduleComponentUpdate: -> - @component?.scheduleUpdate() - - serialize: -> - tokenizedBufferState = @tokenizedBuffer.serialize() - - { - deserializer: 'TextEditor' - version: @serializationVersion - - # TODO: Remove this forward-compatible fallback once 1.8 reaches stable. - displayBuffer: {tokenizedBuffer: tokenizedBufferState} - - tokenizedBuffer: tokenizedBufferState - displayLayerId: @displayLayer.id - selectionsMarkerLayerId: @selectionsMarkerLayer.id - - initialScrollTopRow: @getScrollTopRow() - initialScrollLeftColumn: @getScrollLeftColumn() - - atomicSoftTabs: @displayLayer.atomicSoftTabs - softWrapHangingIndentLength: @displayLayer.softWrapHangingIndent - - @id, @softTabs, @softWrapped, @softWrapAtPreferredLineLength, - @preferredLineLength, @mini, @editorWidthInChars, @width, @largeFileMode, @maxScreenLineLength, - @registered, @invisibles, @showInvisibles, @showIndentGuide, @autoHeight, @autoWidth - } - - subscribeToBuffer: -> - @buffer.retain() - @disposables.add @buffer.onDidChangePath => - @emitter.emit 'did-change-title', @getTitle() - @emitter.emit 'did-change-path', @getPath() - @disposables.add @buffer.onDidChangeEncoding => - @emitter.emit 'did-change-encoding', @getEncoding() - @disposables.add @buffer.onDidDestroy => @destroy() - @disposables.add @buffer.onDidChangeModified => - @terminatePendingState() if not @hasTerminatedPendingState and @buffer.isModified() - - terminatePendingState: -> - @emitter.emit 'did-terminate-pending-state' if not @hasTerminatedPendingState - @hasTerminatedPendingState = true - - onDidTerminatePendingState: (callback) -> - @emitter.on 'did-terminate-pending-state', callback - - subscribeToDisplayLayer: -> - @disposables.add @tokenizedBuffer.onDidChangeGrammar @handleGrammarChange.bind(this) - @disposables.add @displayLayer.onDidChangeSync (e) => - @mergeIntersectingSelections() - @component?.didChangeDisplayLayer(e) - @emitter.emit 'did-change', e - @disposables.add @displayLayer.onDidReset => - @mergeIntersectingSelections() - @component?.didResetDisplayLayer() - @emitter.emit 'did-change', {} - @disposables.add @selectionsMarkerLayer.onDidCreateMarker @addSelection.bind(this) - @disposables.add @selectionsMarkerLayer.onDidUpdate => @component?.didUpdateSelections() - - destroyed: -> - @disposables.dispose() - @displayLayer.destroy() - @tokenizedBuffer.destroy() - selection.destroy() for selection in @selections.slice() - @buffer.release() - @gutterContainer.destroy() - @emitter.emit 'did-destroy' - @emitter.clear() - @component?.element.component = null - @component = null - @lineNumberGutter.element = null - - ### - Section: Event Subscription - ### - - # Essential: Calls your `callback` when the buffer's title has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeTitle: (callback) -> - @emitter.on 'did-change-title', callback - - # Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePath: (callback) -> - @emitter.on 'did-change-path', callback - - # Essential: Invoke the given callback synchronously when the content of the - # buffer changes. - # - # Because observers are invoked synchronously, it's important not to perform - # any expensive operations via this method. Consider {::onDidStopChanging} to - # delay expensive operations until after changes stop occurring. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - # Essential: Invoke `callback` when the buffer's contents change. It is - # emit asynchronously 300ms after the last buffer change. This is a good place - # to handle changes to the buffer without compromising typing performance. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidStopChanging: (callback) -> - @getBuffer().onDidStopChanging(callback) - - # Essential: Calls your `callback` when a {Cursor} is moved. If there are - # multiple cursors, your callback will be called for each cursor. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferPosition` {Point} - # * `oldScreenPosition` {Point} - # * `newBufferPosition` {Point} - # * `newScreenPosition` {Point} - # * `textChanged` {Boolean} - # * `cursor` {Cursor} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeCursorPosition: (callback) -> - @emitter.on 'did-change-cursor-position', callback - - # Essential: Calls your `callback` when a selection's screen range changes. - # - # * `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. - onDidChangeSelectionRange: (callback) -> - @emitter.on 'did-change-selection-range', callback - - # Extended: Calls your `callback` when soft wrap was enabled or disabled. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeSoftWrapped: (callback) -> - @emitter.on 'did-change-soft-wrapped', callback - - # Extended: Calls your `callback` when the buffer's encoding has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeEncoding: (callback) -> - @emitter.on 'did-change-encoding', callback - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. Immediately calls your callback with - # the current grammar. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGrammar: (callback) -> - callback(@getGrammar()) - @onDidChangeGrammar(callback) - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeGrammar: (callback) -> - @emitter.on 'did-change-grammar', callback - - # Extended: Calls your `callback` when the result of {::isModified} changes. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeModified: (callback) -> - @getBuffer().onDidChangeModified(callback) - - # Extended: Calls your `callback` when the buffer's underlying file changes on - # disk at a moment when the result of {::isModified} is true. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidConflict: (callback) -> - @getBuffer().onDidConflict(callback) - - # Extended: Calls your `callback` before text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # * `cancel` {Function} Call to prevent the text from being inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillInsertText: (callback) -> - @emitter.on 'will-insert-text', callback - - # Extended: Calls your `callback` after text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidInsertText: (callback) -> - @emitter.on 'did-insert-text', callback - - # Essential: Invoke the given callback after the buffer is saved to disk. - # - # * `callback` {Function} to be called after the buffer is saved. - # * `event` {Object} with the following keys: - # * `path` The path to which the buffer was saved. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidSave: (callback) -> - @getBuffer().onDidSave(callback) - - # Essential: Invoke the given callback when the editor is destroyed. - # - # * `callback` {Function} to be called when the editor is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # Immediately calls your callback for each existing cursor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeCursors: (callback) -> - callback(cursor) for cursor in @getCursors() - @onDidAddCursor(callback) - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddCursor: (callback) -> - @emitter.on 'did-add-cursor', callback - - # Extended: Calls your `callback` when a {Cursor} is removed from the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveCursor: (callback) -> - @emitter.on 'did-remove-cursor', callback - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # Immediately calls your callback for each existing selection. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeSelections: (callback) -> - callback(selection) for selection in @getSelections() - @onDidAddSelection(callback) - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddSelection: (callback) -> - @emitter.on 'did-add-selection', callback - - # Extended: Calls your `callback` when a {Selection} is removed from the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveSelection: (callback) -> - @emitter.on 'did-remove-selection', callback - - # Extended: Calls your `callback` with each {Decoration} added to the editor. - # Calls your `callback` immediately for any existing decorations. - # - # * `callback` {Function} - # * `decoration` {Decoration} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeDecorations: (callback) -> - @decorationManager.observeDecorations(callback) - - # Extended: Calls your `callback` when a {Decoration} is added to the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddDecoration: (callback) -> - @decorationManager.onDidAddDecoration(callback) - - # Extended: Calls your `callback` when a {Decoration} is removed from the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveDecoration: (callback) -> - @decorationManager.onDidRemoveDecoration(callback) - - # Called by DecorationManager when a decoration is added. - didAddDecoration: (decoration) -> - if decoration.isType('block') - @component?.addBlockDecoration(decoration) - - # Extended: Calls your `callback` when the placeholder text is changed. - # - # * `callback` {Function} - # * `placeholderText` {String} new text - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePlaceholderText: (callback) -> - @emitter.on 'did-change-placeholder-text', callback - - onDidChangeScrollTop: (callback) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.") - - @getElement().onDidChangeScrollTop(callback) - - onDidChangeScrollLeft: (callback) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::onDidChangeScrollLeft instead.") - - @getElement().onDidChangeScrollLeft(callback) - - onDidRequestAutoscroll: (callback) -> - @emitter.on 'did-request-autoscroll', callback - - # TODO Remove once the tabs package no longer uses .on subscriptions - onDidChangeIcon: (callback) -> - @emitter.on 'did-change-icon', callback - - onDidUpdateDecorations: (callback) -> - @decorationManager.onDidUpdateDecorations(callback) - - # Essential: Retrieves the current {TextBuffer}. - getBuffer: -> @buffer - - # Retrieves the current buffer's URI. - getURI: -> @buffer.getUri() - - # Create an {TextEditor} with its initial state based on this object - copy: -> - displayLayer = @displayLayer.copy() - selectionsMarkerLayer = displayLayer.getMarkerLayer(@buffer.getMarkerLayer(@selectionsMarkerLayer.id).copy().id) - softTabs = @getSoftTabs() - new TextEditor({ - @buffer, selectionsMarkerLayer, softTabs, - suppressCursorCreation: true, - tabLength: @tokenizedBuffer.getTabLength(), - initialScrollTopRow: @getScrollTopRow(), - initialScrollLeftColumn: @getScrollLeftColumn(), - @assert, displayLayer, grammar: @getGrammar(), - @autoWidth, @autoHeight, @showCursorOnSelection - }) - - # Controls visibility based on the given {Boolean}. - setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) - - setMini: (mini) -> - @update({mini}) - @mini - - isMini: -> @mini - - onDidChangeMini: (callback) -> - @emitter.on 'did-change-mini', callback - - setLineNumberGutterVisible: (lineNumberGutterVisible) -> @update({lineNumberGutterVisible}) - - isLineNumberGutterVisible: -> @lineNumberGutter.isVisible() - - onDidChangeLineNumberGutterVisible: (callback) -> - @emitter.on 'did-change-line-number-gutter-visible', callback - - # Essential: Calls your `callback` when a {Gutter} is added to the editor. - # Immediately calls your callback for each existing gutter. - # - # * `callback` {Function} - # * `gutter` {Gutter} that currently exists/was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGutters: (callback) -> - @gutterContainer.observeGutters callback - - # Essential: Calls your `callback` when a {Gutter} is added to the editor. - # - # * `callback` {Function} - # * `gutter` {Gutter} that was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddGutter: (callback) -> - @gutterContainer.onDidAddGutter callback - - # Essential: Calls your `callback` when a {Gutter} is removed from the editor. - # - # * `callback` {Function} - # * `name` The name of the {Gutter} that was removed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveGutter: (callback) -> - @gutterContainer.onDidRemoveGutter callback - - # Set the number of characters that can be displayed horizontally in the - # editor. - # - # * `editorWidthInChars` A {Number} representing the width of the - # {TextEditorElement} in characters. - setEditorWidthInChars: (editorWidthInChars) -> @update({editorWidthInChars}) - - # Returns the editor width in characters. - getEditorWidthInChars: -> - if @width? and @defaultCharWidth > 0 - Math.max(0, Math.floor(@width / @defaultCharWidth)) - else - @editorWidthInChars - - ### - Section: File Details - ### - - # Essential: Get the editor's title for display in other parts of the - # UI such as the tabs. - # - # If the editor's buffer is saved, its title is the file name. If it is - # unsaved, its title is "untitled". - # - # Returns a {String}. - getTitle: -> - @getFileName() ? 'untitled' - - # Essential: Get unique title for display in other parts of the UI, such as - # the window title. - # - # If the editor's buffer is unsaved, its title is "untitled" - # If the editor's buffer is saved, its unique title is formatted as one - # of the following, - # * "" when it is the only editing buffer with this file name. - # * " — " when other buffers have this file name. - # - # Returns a {String} - getLongTitle: -> - if @getPath() - fileName = @getFileName() - - allPathSegments = [] - for textEditor in atom.workspace.getTextEditors() when textEditor isnt this - if textEditor.getFileName() is fileName - directoryPath = fs.tildify(textEditor.getDirectoryPath()) - allPathSegments.push(directoryPath.split(path.sep)) - - if allPathSegments.length is 0 - return fileName - - ourPathSegments = fs.tildify(@getDirectoryPath()).split(path.sep) - allPathSegments.push ourPathSegments - - loop - firstSegment = ourPathSegments[0] - - commonBase = _.all(allPathSegments, (pathSegments) -> pathSegments.length > 1 and pathSegments[0] is firstSegment) - if commonBase - pathSegments.shift() for pathSegments in allPathSegments - else - break - - "#{fileName} \u2014 #{path.join(pathSegments...)}" - else - 'untitled' - - # Essential: Returns the {String} path of this editor's text buffer. - getPath: -> @buffer.getPath() - - getFileName: -> - if fullPath = @getPath() - path.basename(fullPath) - else - null - - getDirectoryPath: -> - if fullPath = @getPath() - path.dirname(fullPath) - else - null - - # Extended: Returns the {String} character set encoding of this editor's text - # buffer. - getEncoding: -> @buffer.getEncoding() - - # Extended: Set the character set encoding to use in this editor's text - # buffer. - # - # * `encoding` The {String} character set encoding name such as 'utf8' - setEncoding: (encoding) -> @buffer.setEncoding(encoding) - - # Essential: Returns {Boolean} `true` if this editor has been modified. - isModified: -> @buffer.isModified() - - # Essential: Returns {Boolean} `true` if this editor has no content. - isEmpty: -> @buffer.isEmpty() - - ### - Section: File Operations - ### - - # Essential: Saves the editor's text buffer. - # - # See {TextBuffer::save} for more details. - save: -> @buffer.save() - - # Essential: Saves the editor's text buffer as the given path. - # - # See {TextBuffer::saveAs} for more details. - # - # * `filePath` A {String} path. - saveAs: (filePath) -> @buffer.saveAs(filePath) - - # Determine whether the user should be prompted to save before closing - # this editor. - shouldPromptToSave: ({windowCloseRequested, projectHasPaths}={}) -> - if windowCloseRequested and projectHasPaths and atom.stateStore.isConnected() - @buffer.isInConflict() - else - @isModified() and not @buffer.hasMultipleEditors() - - # Returns an {Object} to configure dialog shown when this editor is saved - # via {Pane::saveItemAs}. - getSaveDialogOptions: -> {} - - ### - Section: Reading Text - ### - - # Essential: Returns a {String} representing the entire contents of the editor. - getText: -> @buffer.getText() - - # Essential: Get the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # - # Returns a {String}. - getTextInBufferRange: (range) -> - @buffer.getTextInRange(range) - - # Essential: Returns a {Number} representing the number of lines in the buffer. - getLineCount: -> @buffer.getLineCount() - - # Essential: Returns a {Number} representing the number of screen lines in the - # editor. This accounts for folds. - getScreenLineCount: -> @displayLayer.getScreenLineCount() - - getApproximateScreenLineCount: -> @displayLayer.getApproximateScreenLineCount() - - # Essential: Returns a {Number} representing the last zero-indexed buffer row - # number of the editor. - getLastBufferRow: -> @buffer.getLastRow() - - # Essential: Returns a {Number} representing the last zero-indexed screen row - # number of the editor. - getLastScreenRow: -> @getScreenLineCount() - 1 - - # Essential: Returns a {String} representing the contents of the line at the - # given buffer row. - # - # * `bufferRow` A {Number} representing a zero-indexed buffer row. - lineTextForBufferRow: (bufferRow) -> @buffer.lineForRow(bufferRow) - - # Essential: Returns a {String} representing the contents of the line at the - # given screen row. - # - # * `screenRow` A {Number} representing a zero-indexed screen row. - lineTextForScreenRow: (screenRow) -> - @screenLineForScreenRow(screenRow)?.lineText - - logScreenLines: (start=0, end=@getLastScreenRow()) -> - for row in [start..end] - line = @lineTextForScreenRow(row) - console.log row, @bufferRowForScreenRow(row), line, line.length - return - - tokensForScreenRow: (screenRow) -> - tokens = [] - lineTextIndex = 0 - currentTokenScopes = [] - {lineText, tags} = @screenLineForScreenRow(screenRow) - for tag in tags - if @displayLayer.isOpenTag(tag) - currentTokenScopes.push(@displayLayer.classNameForTag(tag)) - else if @displayLayer.isCloseTag(tag) - currentTokenScopes.pop() - else - tokens.push({ - text: lineText.substr(lineTextIndex, tag) - scopes: currentTokenScopes.slice() - }) - lineTextIndex += tag - tokens - - screenLineForScreenRow: (screenRow) -> - @displayLayer.getScreenLine(screenRow) - - bufferRowForScreenRow: (screenRow) -> - @displayLayer.translateScreenPosition(Point(screenRow, 0)).row - - bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - @displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow + 1) - - screenRowForBufferRow: (row) -> - @displayLayer.translateBufferPosition(Point(row, 0)).row - - getRightmostScreenPosition: -> @displayLayer.getRightmostScreenPosition() - - getApproximateRightmostScreenPosition: -> @displayLayer.getApproximateRightmostScreenPosition() - - getMaxScreenLineLength: -> @getRightmostScreenPosition().column - - getLongestScreenRow: -> @getRightmostScreenPosition().row - - getApproximateLongestScreenRow: -> @getApproximateRightmostScreenPosition().row - - lineLengthForScreenRow: (screenRow) -> @displayLayer.lineLengthForScreenRow(screenRow) - - # Returns the range for the given buffer row. - # - # * `row` A row {Number}. - # * `options` (optional) An options hash with an `includeNewline` key. - # - # Returns a {Range}. - bufferRangeForBufferRow: (row, {includeNewline}={}) -> @buffer.rangeForRow(row, includeNewline) - - # Get the text in the given {Range}. - # - # Returns a {String}. - getTextInRange: (range) -> @buffer.getTextInRange(range) - - # {Delegates to: TextBuffer.isRowBlank} - isBufferRowBlank: (bufferRow) -> @buffer.isRowBlank(bufferRow) - - # {Delegates to: TextBuffer.nextNonBlankRow} - nextNonBlankBufferRow: (bufferRow) -> @buffer.nextNonBlankRow(bufferRow) - - # {Delegates to: TextBuffer.getEndPosition} - getEofBufferPosition: -> @buffer.getEndPosition() - - # Essential: Get the {Range} of the paragraph surrounding the most recently added - # cursor. - # - # Returns a {Range}. - getCurrentParagraphBufferRange: -> - @getLastCursor().getCurrentParagraphBufferRange() - - - ### - Section: Mutating Text - ### - - # Essential: Replaces the entire contents of the buffer with the given {String}. - # - # * `text` A {String} to replace with - setText: (text) -> @buffer.setText(text) - - # Essential: Set the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # * `text` A {String} - # * `options` (optional) {Object} - # * `normalizeLineEndings` (optional) {Boolean} (default: true) - # * `undo` (optional) {String} 'skip' will skip the undo system - # - # Returns the {Range} of the newly-inserted text. - setTextInBufferRange: (range, text, options) -> @getBuffer().setTextInRange(range, text, options) - - # Essential: For each selection, replace the selected text with the given text. - # - # * `text` A {String} representing the text to insert. - # * `options` (optional) See {Selection::insertText}. - # - # Returns a {Range} when the text has been inserted - # Returns a {Boolean} false when the text has not been inserted - insertText: (text, options={}) -> - return false unless @emitWillInsertTextEvent(text) - - groupingInterval = if options.groupUndo - @undoGroupingInterval - else - 0 - - options.autoIndentNewline ?= @shouldAutoIndent() - options.autoDecreaseIndent ?= @shouldAutoIndent() - @mutateSelectedText( - (selection) => - range = selection.insertText(text, options) - didInsertEvent = {text, range} - @emitter.emit 'did-insert-text', didInsertEvent - range - , groupingInterval - ) - - # Essential: For each selection, replace the selected text with a newline. - insertNewline: (options) -> - @insertText('\n', options) - - # Essential: For each selection, if the selection is empty, delete the character - # following the cursor. Otherwise delete the selected text. - delete: -> - @mutateSelectedText (selection) -> selection.delete() - - # Essential: For each selection, if the selection is empty, delete the character - # preceding the cursor. Otherwise delete the selected text. - backspace: -> - @mutateSelectedText (selection) -> selection.backspace() - - # Extended: Mutate the text of all the selections in a single transaction. - # - # All the changes made inside the given {Function} can be reverted with a - # single call to {::undo}. - # - # * `fn` A {Function} that will be called once for each {Selection}. The first - # argument will be a {Selection} and the second argument will be the - # {Number} index of that selection. - mutateSelectedText: (fn, groupingInterval=0) -> - @mergeIntersectingSelections => - @transact groupingInterval, => - fn(selection, index) for selection, index in @getSelectionsOrderedByBufferPosition() - - # Move lines intersecting the most recent selection or multiple selections - # up by one row in screen coordinates. - moveLineUp: -> - selections = @getSelectedBufferRanges().sort((a, b) -> a.compare(b)) - - if selections[0].start.row is 0 - return - - if selections[selections.length - 1].start.row is @getLastBufferRow() and @buffer.getLastLine() is '' - return - - @transact => - newSelectionRanges = [] - - while selections.length > 0 - # Find selections spanning a contiguous set of lines - selection = selections.shift() - selectionsToMove = [selection] - - while selection.end.row is selections[0]?.start.row - selectionsToMove.push(selections[0]) - selection.end.row = selections[0].end.row - selections.shift() - - # Compute the buffer range spanned by all these selections, expanding it - # so that it includes any folded region that intersects them. - startRow = selection.start.row - endRow = selection.end.row - if selection.end.row > selection.start.row and selection.end.column is 0 - # Don't move the last line of a multi-line selection if the selection ends at column 0 - endRow-- - - startRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow) - endRow = @displayLayer.findBoundaryFollowingBufferRow(endRow + 1) - linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) - - # If selected line range is preceded by a fold, one line above on screen - # could be multiple lines in the buffer. - precedingRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow - 1) - insertDelta = linesRange.start.row - precedingRow - - # Any folds in the text that is moved will need to be re-created. - # It includes the folds that were intersecting with the selection. - rangesToRefold = @displayLayer - .destroyFoldsIntersectingBufferRange(linesRange) - .map((range) -> range.translate([-insertDelta, 0])) - - # Delete lines spanned by selection and insert them on the preceding buffer row - lines = @buffer.getTextInRange(linesRange) - lines += @buffer.lineEndingForRow(linesRange.end.row - 2) unless lines[lines.length - 1] is '\n' - @buffer.delete(linesRange) - @buffer.insert([precedingRow, 0], lines) - - # Restore folds that existed before the lines were moved - for rangeToRefold in rangesToRefold - @displayLayer.foldBufferRange(rangeToRefold) - - for selection in selectionsToMove - newSelectionRanges.push(selection.translate([-insertDelta, 0])) - - @setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) - @autoIndentSelectedRows() if @shouldAutoIndent() - @scrollToBufferPosition([newSelectionRanges[0].start.row, 0]) - - # Move lines intersecting the most recent selection or multiple selections - # down by one row in screen coordinates. - moveLineDown: -> - selections = @getSelectedBufferRanges() - selections.sort (a, b) -> a.compare(b) - selections = selections.reverse() - - @transact => - @consolidateSelections() - newSelectionRanges = [] - - while selections.length > 0 - # Find selections spanning a contiguous set of lines - selection = selections.shift() - selectionsToMove = [selection] - - # if the current selection start row matches the next selections' end row - make them one selection - while selection.start.row is selections[0]?.end.row - selectionsToMove.push(selections[0]) - selection.start.row = selections[0].start.row - selections.shift() - - # Compute the buffer range spanned by all these selections, expanding it - # so that it includes any folded region that intersects them. - startRow = selection.start.row - endRow = selection.end.row - if selection.end.row > selection.start.row and selection.end.column is 0 - # Don't move the last line of a multi-line selection if the selection ends at column 0 - endRow-- - - startRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow) - endRow = @displayLayer.findBoundaryFollowingBufferRow(endRow + 1) - linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) - - # If selected line range is followed by a fold, one line below on screen - # could be multiple lines in the buffer. But at the same time, if the - # next buffer row is wrapped, one line in the buffer can represent many - # screen rows. - followingRow = Math.min(@buffer.getLineCount(), @displayLayer.findBoundaryFollowingBufferRow(endRow + 1)) - insertDelta = followingRow - linesRange.end.row - - # Any folds in the text that is moved will need to be re-created. - # It includes the folds that were intersecting with the selection. - rangesToRefold = @displayLayer - .destroyFoldsIntersectingBufferRange(linesRange) - .map((range) -> range.translate([insertDelta, 0])) - - # Delete lines spanned by selection and insert them on the following correct buffer row - lines = @buffer.getTextInRange(linesRange) - if followingRow - 1 is @buffer.getLastRow() - lines = "\n#{lines}" - - @buffer.insert([followingRow, 0], lines) - @buffer.delete(linesRange) - - # Restore folds that existed before the lines were moved - for rangeToRefold in rangesToRefold - @displayLayer.foldBufferRange(rangeToRefold) - - for selection in selectionsToMove - newSelectionRanges.push(selection.translate([insertDelta, 0])) - - @setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) - @autoIndentSelectedRows() if @shouldAutoIndent() - @scrollToBufferPosition([newSelectionRanges[0].start.row - 1, 0]) - - # Move any active selections one column to the left. - moveSelectionLeft: -> - selections = @getSelectedBufferRanges() - noSelectionAtStartOfLine = selections.every((selection) -> - selection.start.column isnt 0 - ) - - translationDelta = [0, -1] - translatedRanges = [] - - if noSelectionAtStartOfLine - @transact => - for selection in selections - charToLeftOfSelection = new Range(selection.start.translate(translationDelta), selection.start) - charTextToLeftOfSelection = @buffer.getTextInRange(charToLeftOfSelection) - - @buffer.insert(selection.end, charTextToLeftOfSelection) - @buffer.delete(charToLeftOfSelection) - translatedRanges.push(selection.translate(translationDelta)) - - @setSelectedBufferRanges(translatedRanges) - - # Move any active selections one column to the right. - moveSelectionRight: -> - selections = @getSelectedBufferRanges() - noSelectionAtEndOfLine = selections.every((selection) => - selection.end.column isnt @buffer.lineLengthForRow(selection.end.row) - ) - - translationDelta = [0, 1] - translatedRanges = [] - - if noSelectionAtEndOfLine - @transact => - for selection in selections - charToRightOfSelection = new Range(selection.end, selection.end.translate(translationDelta)) - charTextToRightOfSelection = @buffer.getTextInRange(charToRightOfSelection) - - @buffer.delete(charToRightOfSelection) - @buffer.insert(selection.start, charTextToRightOfSelection) - translatedRanges.push(selection.translate(translationDelta)) - - @setSelectedBufferRanges(translatedRanges) - - duplicateLines: -> - @transact => - selections = @getSelectionsOrderedByBufferPosition() - previousSelectionRanges = [] - - i = selections.length - 1 - while i >= 0 - j = i - previousSelectionRanges[i] = selections[i].getBufferRange() - if selections[i].isEmpty() - {start} = selections[i].getScreenRange() - selections[i].setScreenRange([[start.row, 0], [start.row + 1, 0]], preserveFolds: true) - [startRow, endRow] = selections[i].getBufferRowRange() - endRow++ - while i > 0 - [previousSelectionStartRow, previousSelectionEndRow] = selections[i - 1].getBufferRowRange() - if previousSelectionEndRow is startRow - startRow = previousSelectionStartRow - previousSelectionRanges[i - 1] = selections[i - 1].getBufferRange() - i-- - else - break - - intersectingFolds = @displayLayer.foldsIntersectingBufferRange([[startRow, 0], [endRow, 0]]) - textToDuplicate = @getTextInBufferRange([[startRow, 0], [endRow, 0]]) - textToDuplicate = '\n' + textToDuplicate if endRow > @getLastBufferRow() - @buffer.insert([endRow, 0], textToDuplicate) - - insertedRowCount = endRow - startRow - - for k in [i..j] by 1 - selections[k].setBufferRange(previousSelectionRanges[k].translate([insertedRowCount, 0])) - - for fold in intersectingFolds - foldRange = @displayLayer.bufferRangeForFold(fold) - @displayLayer.foldBufferRange(foldRange.translate([insertedRowCount, 0])) - - i-- - - replaceSelectedText: (options={}, fn) -> - {selectWordIfEmpty} = options - @mutateSelectedText (selection) -> - selection.getBufferRange() - if selectWordIfEmpty and selection.isEmpty() - selection.selectWord() - text = selection.getText() - selection.deleteSelectedText() - range = selection.insertText(fn(text)) - selection.setBufferRange(range) - - # Split multi-line selections into one selection per line. - # - # Operates on all selections. This method breaks apart all multi-line - # selections to create multiple single-line selections that cumulatively cover - # the same original area. - splitSelectionsIntoLines: -> - @mergeIntersectingSelections => - for selection in @getSelections() - range = selection.getBufferRange() - continue if range.isSingleLine() - - {start, end} = range - @addSelectionForBufferRange([start, [start.row, Infinity]]) - {row} = start - while ++row < end.row - @addSelectionForBufferRange([[row, 0], [row, Infinity]]) - @addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) unless end.column is 0 - selection.destroy() - return - - # Extended: For each selection, transpose the selected text. - # - # If the selection is empty, the characters preceding and following the cursor - # are swapped. Otherwise, the selected characters are reversed. - transpose: -> - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectRight() - text = selection.getText() - selection.delete() - selection.cursor.moveLeft() - selection.insertText text - else - selection.insertText selection.getText().split('').reverse().join('') - - # Extended: Convert the selected text to upper case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - upperCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toUpperCase() - - # Extended: Convert the selected text to lower case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - lowerCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toLowerCase() - - # Extended: Toggle line comments for rows intersecting selections. - # - # If the current grammar doesn't support comments, does nothing. - toggleLineCommentsInSelection: -> - @mutateSelectedText (selection) -> selection.toggleLineComments() - - # Convert multiple lines to a single line. - # - # Operates on all selections. If the selection is empty, joins the current - # line with the next line. Otherwise it joins all lines that intersect the - # selection. - # - # Joining a line means that multiple lines are converted to a single line with - # the contents of each of the original non-empty lines separated by a space. - joinLines: -> - @mutateSelectedText (selection) -> selection.joinLines() - - # Extended: For each cursor, insert a newline at beginning the following line. - insertNewlineBelow: -> - @transact => - @moveToEndOfLine() - @insertNewline() - - # Extended: For each cursor, insert a newline at the end of the preceding line. - insertNewlineAbove: -> - @transact => - bufferRow = @getCursorBufferPosition().row - indentLevel = @indentationForBufferRow(bufferRow) - onFirstLine = bufferRow is 0 - - @moveToBeginningOfLine() - @moveLeft() - @insertNewline() - - if @shouldAutoIndent() and @indentationForBufferRow(bufferRow) < indentLevel - @setIndentationForBufferRow(bufferRow, indentLevel) - - if onFirstLine - @moveUp() - @moveToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfWord() - - # Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the - # previous word boundary. - deleteToPreviousWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToPreviousWordBoundary() - - # Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the - # next word boundary. - deleteToNextWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToNextWordBoundary() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing subword following the cursor. Otherwise delete the selected - # text. - deleteToBeginningOfSubword: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfSubword() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing subword following the cursor. Otherwise delete the selected - # text. - deleteToEndOfSubword: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfSubword() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing line that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfLine() - - # Extended: For each selection, if the selection is not empty, deletes the - # selection; otherwise, deletes all characters of the containing line - # following the cursor. If the cursor is already at the end of the line, - # deletes the following newline. - deleteToEndOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word following the cursor. Otherwise delete the selected - # text. - deleteToEndOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfWord() - - # Extended: Delete all lines intersecting selections. - deleteLine: -> - @mergeSelectionsOnSameRows() - @mutateSelectedText (selection) -> selection.deleteLine() - - ### - Section: History - ### - - # Essential: Undo the last change. - undo: -> - @avoidMergingSelections => @buffer.undo() - @getLastSelection().autoscroll() - - # Essential: Redo the last change. - redo: -> - @avoidMergingSelections => @buffer.redo() - @getLastSelection().autoscroll() - - # Extended: Batch multiple operations as a single undo/redo step. - # - # Any group of operations that are logically grouped from the perspective of - # undoing and redoing should be performed in a transaction. If you want to - # abort the transaction, call {::abortTransaction} to terminate the function's - # execution and revert any changes performed up to the abortion. - # - # * `groupingInterval` (optional) The {Number} of milliseconds for which this - # transaction should be considered 'groupable' after it begins. If a transaction - # with a positive `groupingInterval` is committed while the previous transaction is - # still 'groupable', the two transactions are merged with respect to undo and redo. - # * `fn` A {Function} to call inside the transaction. - transact: (groupingInterval, fn) -> - @buffer.transact(groupingInterval, fn) - - # Deprecated: Start an open-ended transaction. - beginTransaction: (groupingInterval) -> - Grim.deprecate('Transactions should be performed via TextEditor::transact only') - @buffer.beginTransaction(groupingInterval) - - # Deprecated: Commit an open-ended transaction started with {::beginTransaction}. - commitTransaction: -> - Grim.deprecate('Transactions should be performed via TextEditor::transact only') - @buffer.commitTransaction() - - # Extended: Abort an open transaction, undoing any operations performed so far - # within the transaction. - abortTransaction: -> @buffer.abortTransaction() - - # Extended: Create a pointer to the current state of the buffer for use - # with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. - # - # Returns a checkpoint value. - createCheckpoint: -> @buffer.createCheckpoint() - - # Extended: Revert the buffer to the state it was in when the given - # checkpoint was created. - # - # The redo stack will be empty following this operation, so changes since the - # checkpoint will be lost. If the given checkpoint is no longer present in the - # undo history, no changes will be made to the buffer and this method will - # return `false`. - # - # * `checkpoint` The checkpoint to revert to. - # - # Returns a {Boolean} indicating whether the operation succeeded. - revertToCheckpoint: (checkpoint) -> @buffer.revertToCheckpoint(checkpoint) - - # Extended: Group all changes since the given checkpoint into a single - # transaction for purposes of undo/redo. - # - # If the given checkpoint is no longer present in the undo history, no - # grouping will be performed and this method will return `false`. - # - # * `checkpoint` The checkpoint from which to group changes. - # - # Returns a {Boolean} indicating whether the operation succeeded. - groupChangesSinceCheckpoint: (checkpoint) -> @buffer.groupChangesSinceCheckpoint(checkpoint) - - ### - Section: TextEditor Coordinates - ### - - # Essential: Convert a position in buffer-coordinates to screen-coordinates. - # - # The position is clipped via {::clipBufferPosition} prior to the conversion. - # The position is also clipped via {::clipScreenPosition} following the - # conversion, which only makes a difference when `options` are supplied. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - screenPositionForBufferPosition: (bufferPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.translateBufferPosition(bufferPosition, options) - - # Essential: Convert a position in screen-coordinates to buffer-coordinates. - # - # The position is clipped via {::clipScreenPosition} prior to the conversion. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - bufferPositionForScreenPosition: (screenPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.translateScreenPosition(screenPosition, options) - - # Essential: Convert a range in buffer-coordinates to screen-coordinates. - # - # * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. - # - # Returns a {Range}. - screenRangeForBufferRange: (bufferRange, options) -> - bufferRange = Range.fromObject(bufferRange) - start = @screenPositionForBufferPosition(bufferRange.start, options) - end = @screenPositionForBufferPosition(bufferRange.end, options) - new Range(start, end) - - # Essential: Convert a range in screen-coordinates to buffer-coordinates. - # - # * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. - # - # Returns a {Range}. - bufferRangeForScreenRange: (screenRange) -> - screenRange = Range.fromObject(screenRange) - start = @bufferPositionForScreenPosition(screenRange.start) - end = @bufferPositionForScreenPosition(screenRange.end) - new Range(start, end) - - # Extended: Clip the given {Point} to a valid position in the buffer. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the buffer, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at buffer row 2 is 10 characters long - # editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `bufferPosition` The {Point} representing the position to clip. - # - # Returns a {Point}. - clipBufferPosition: (bufferPosition) -> @buffer.clipPosition(bufferPosition) - - # Extended: Clip the start and end of the given range to valid positions in the - # buffer. See {::clipBufferPosition} for more information. - # - # * `range` The {Range} to clip. - # - # Returns a {Range}. - clipBufferRange: (range) -> @buffer.clipRange(range) - - # Extended: Clip the given {Point} to a valid position on screen. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the screen, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at screen row 2 is 10 characters long - # editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `screenPosition` The {Point} representing the position to clip. - # * `options` (optional) {Object} - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. - # - # Returns a {Point}. - clipScreenPosition: (screenPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.clipScreenPosition(screenPosition, options) - - # Extended: Clip the start and end of the given range to valid positions on screen. - # See {::clipScreenPosition} for more information. - # - # * `range` The {Range} to clip. - # * `options` (optional) See {::clipScreenPosition} `options`. - # - # Returns a {Range}. - clipScreenRange: (screenRange, options) -> - screenRange = Range.fromObject(screenRange) - start = @displayLayer.clipScreenPosition(screenRange.start, options) - end = @displayLayer.clipScreenPosition(screenRange.end, options) - Range(start, end) - - ### - Section: Decorations - ### - - # Essential: Add a decoration that tracks a {DisplayMarker}. When the - # marker moves, is invalidated, or is destroyed, the decoration will be - # updated to reflect the marker's state. - # - # The following are the supported decorations types: - # - # * __line__: Adds your CSS `class` to the line nodes within the range - # marked by the marker - # * __line-number__: Adds your CSS `class` to the line number nodes within the - # range marked by the marker - # * __highlight__: Adds a new highlight div to the editor surrounding the - # range marked by the marker. When the user selects text, the selection is - # visualized with a highlight decoration internally. The structure of this - # highlight will be - # ```html - #
- # - #
- #
- # ``` - # * __overlay__: Positions the view associated with the given item at the head - # or tail of the given `DisplayMarker`. - # * __gutter__: A decoration that tracks a {DisplayMarker} in a {Gutter}. Gutter - # decorations are created by calling {Gutter::decorateMarker} on the - # desired `Gutter` instance. - # * __block__: Positions the view associated with the given item before or - # after the row of the given `TextEditorMarker`. - # - # ## Arguments - # - # * `marker` A {DisplayMarker} you want this decoration to follow. - # * `decorationParams` An {Object} representing the decoration e.g. - # `{type: 'line-number', class: 'linter-error'}` - # * `type` There are several supported decoration types. The behavior of the - # types are as follows: - # * `line` Adds the given `class` to the lines overlapping the rows - # spanned by the `DisplayMarker`. - # * `line-number` Adds the given `class` to the line numbers overlapping - # the rows spanned by the `DisplayMarker`. - # * `text` Injects spans into all text overlapping the marked range, - # then adds the given `class` or `style` properties to these spans. - # Use this to manipulate the foreground color or styling of text in - # a given range. - # * `highlight` Creates an absolutely-positioned `.highlight` div - # containing nested divs to cover the marked region. For example, this - # is used to implement selections. - # * `overlay` Positions the view associated with the given item at the - # head or tail of the given `DisplayMarker`, depending on the `position` - # property. - # * `gutter` Tracks a {DisplayMarker} in a {Gutter}. Created by calling - # {Gutter::decorateMarker} on the desired `Gutter` instance. - # * `block` Positions the view associated with the given item before or - # after the row of the given `TextEditorMarker`, depending on the `position` - # property. - # * `cursor` Renders a cursor at the head of the given marker. If multiple - # decorations are created for the same marker, their class strings and - # style objects are combined into a single cursor. You can use this - # decoration type to style existing cursors by passing in their markers - # or render artificial cursors that don't actually exist in the model - # by passing a marker that isn't actually associated with a cursor. - # * `class` This CSS class will be applied to the decorated line number, - # line, text spans, highlight regions, cursors, or overlay. - # * `style` An {Object} containing CSS style properties to apply to the - # relevant DOM node. Currently this only works with a `type` of `cursor` - # or `text`. - # * `item` (optional) An {HTMLElement} or a model {Object} with a - # corresponding view registered. Only applicable to the `gutter`, - # `overlay` and `block` decoration types. - # * `onlyHead` (optional) If `true`, the decoration will only be applied to - # the head of the `DisplayMarker`. Only applicable to the `line` and - # `line-number` decoration types. - # * `onlyEmpty` (optional) If `true`, the decoration will only be applied if - # the associated `DisplayMarker` is empty. Only applicable to the `gutter`, - # `line`, and `line-number` decoration types. - # * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied - # if the associated `DisplayMarker` is non-empty. Only applicable to the - # `gutter`, `line`, and `line-number` decoration types. - # * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied - # to the last row of a non-empty range, even if it ends at column 0. - # Defaults to `true`. Only applicable to the `gutter`, `line`, and - # `line-number` decoration types. - # * `position` (optional) Only applicable to decorations of type `overlay` and `block`. - # Controls where the view is positioned relative to the `TextEditorMarker`. - # Values can be `'head'` (the default) or `'tail'` for overlay decorations, and - # `'before'` (the default) or `'after'` for block decorations. - # * `avoidOverflow` (optional) Only applicable to decorations of type - # `overlay`. Determines whether the decoration adjusts its horizontal or - # vertical position to remain fully visible when it would otherwise - # overflow the editor. Defaults to `true`. - # - # Returns a {Decoration} object - decorateMarker: (marker, decorationParams) -> - @decorationManager.decorateMarker(marker, decorationParams) - - # Essential: Add a decoration to every marker in the given marker layer. Can - # be used to decorate a large number of markers without having to create and - # manage many individual decorations. - # - # * `markerLayer` A {DisplayMarkerLayer} or {MarkerLayer} to decorate. - # * `decorationParams` The same parameters that are passed to - # {TextEditor::decorateMarker}, except the `type` cannot be `overlay` or `gutter`. - # - # Returns a {LayerDecoration}. - decorateMarkerLayer: (markerLayer, decorationParams) -> - @decorationManager.decorateMarkerLayer(markerLayer, decorationParams) - - # Deprecated: Get all the decorations within a screen row range on the default - # layer. - # - # * `startScreenRow` the {Number} beginning screen row - # * `endScreenRow` the {Number} end screen row (inclusive) - # - # Returns an {Object} of decorations in the form - # `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` - # where the keys are {DisplayMarker} IDs, and the values are an array of decoration - # params objects attached to the marker. - # Returns an empty object when no decorations are found - decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> - @decorationManager.decorationsForScreenRowRange(startScreenRow, endScreenRow) - - decorationsStateForScreenRowRange: (startScreenRow, endScreenRow) -> - @decorationManager.decorationsStateForScreenRowRange(startScreenRow, endScreenRow) - - # Extended: Get all decorations. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getDecorations: (propertyFilter) -> - @decorationManager.getDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineDecorations: (propertyFilter) -> - @decorationManager.getLineDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line-number'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineNumberDecorations: (propertyFilter) -> - @decorationManager.getLineNumberDecorations(propertyFilter) - - # Extended: Get all decorations of type 'highlight'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getHighlightDecorations: (propertyFilter) -> - @decorationManager.getHighlightDecorations(propertyFilter) - - # Extended: Get all decorations of type 'overlay'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getOverlayDecorations: (propertyFilter) -> - @decorationManager.getOverlayDecorations(propertyFilter) - - ### - Section: Markers - ### - - # Essential: Create a marker on the default marker layer with the given range - # in buffer coordinates. This marker will maintain its logical location as the - # buffer is changed, so if you mark a particular word, the marker will remain - # over that word even if the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `maintainHistory` (optional) {Boolean} Whether to store this marker's - # range before and after each change in the undo history. This allows the - # marker's position to be restored more accurately for certain undo/redo - # operations, but uses more time and memory. (default: false) - # * `reversed` (optional) {Boolean} Creates the marker in a reversed - # orientation. (default: false) - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markBufferRange: (bufferRange, options) -> - @defaultMarkerLayer.markBufferRange(bufferRange, options) - - # Essential: Create a marker on the default marker layer with the given range - # in screen coordinates. This marker will maintain its logical location as the - # buffer is changed, so if you mark a particular word, the marker will remain - # over that word even if the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `maintainHistory` (optional) {Boolean} Whether to store this marker's - # range before and after each change in the undo history. This allows the - # marker's position to be restored more accurately for certain undo/redo - # operations, but uses more time and memory. (default: false) - # * `reversed` (optional) {Boolean} Creates the marker in a reversed - # orientation. (default: false) - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markScreenRange: (screenRange, options) -> - @defaultMarkerLayer.markScreenRange(screenRange, options) - - # Essential: Create a marker on the default marker layer with the given buffer - # position and no tail. To group multiple markers together in their own - # private layer, see {::addMarkerLayer}. - # - # * `bufferPosition` A {Point} or point-compatible {Array} - # * `options` (optional) An {Object} with the following keys: - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markBufferPosition: (bufferPosition, options) -> - @defaultMarkerLayer.markBufferPosition(bufferPosition, options) - - # Essential: Create a marker on the default marker layer with the given screen - # position and no tail. To group multiple markers together in their own - # private layer, see {::addMarkerLayer}. - # - # * `screenPosition` A {Point} or point-compatible {Array} - # * `options` (optional) An {Object} with the following keys: - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. - # - # Returns a {DisplayMarker}. - markScreenPosition: (screenPosition, options) -> - @defaultMarkerLayer.markScreenPosition(screenPosition, options) - - # Essential: Find all {DisplayMarker}s on the default marker layer that - # match the given properties. - # - # This method finds markers based on the given properties. Markers can be - # associated with custom properties that will be compared with basic equality. - # In addition, there are several special properties that will be compared - # with the range of the markers rather than their properties. - # - # * `properties` An {Object} containing properties that each returned marker - # must satisfy. Markers can be associated with custom properties, which are - # compared with basic equality. In addition, several reserved properties - # can be used to filter markers based on their current range: - # * `startBufferRow` Only include markers starting at this row in buffer - # coordinates. - # * `endBufferRow` Only include markers ending at this row in buffer - # coordinates. - # * `containsBufferRange` Only include markers containing this {Range} or - # in range-compatible {Array} in buffer coordinates. - # * `containsBufferPosition` Only include markers containing this {Point} - # or {Array} of `[row, column]` in buffer coordinates. - # - # Returns an {Array} of {DisplayMarker}s - findMarkers: (params) -> - @defaultMarkerLayer.findMarkers(params) - - # Extended: Get the {DisplayMarker} on the default layer for the given - # marker id. - # - # * `id` {Number} id of the marker - getMarker: (id) -> - @defaultMarkerLayer.getMarker(id) - - # Extended: Get all {DisplayMarker}s on the default marker layer. Consider - # using {::findMarkers} - getMarkers: -> - @defaultMarkerLayer.getMarkers() - - # Extended: Get the number of markers in the default marker layer. - # - # Returns a {Number}. - getMarkerCount: -> - @defaultMarkerLayer.getMarkerCount() - - destroyMarker: (id) -> - @getMarker(id)?.destroy() - - # Essential: Create a marker layer to group related markers. - # - # * `options` An {Object} containing the following keys: - # * `maintainHistory` A {Boolean} indicating whether marker state should be - # restored on undo/redo. Defaults to `false`. - # * `persistent` A {Boolean} indicating whether or not this marker layer - # should be serialized and deserialized along with the rest of the - # buffer. Defaults to `false`. If `true`, the marker layer's id will be - # maintained across the serialization boundary, allowing you to retrieve - # it via {::getMarkerLayer}. - # - # Returns a {DisplayMarkerLayer}. - addMarkerLayer: (options) -> - @displayLayer.addMarkerLayer(options) - - # Essential: Get a {DisplayMarkerLayer} by id. - # - # * `id` The id of the marker layer to retrieve. - # - # Returns a {DisplayMarkerLayer} or `undefined` if no layer exists with the - # given id. - getMarkerLayer: (id) -> - @displayLayer.getMarkerLayer(id) - - # Essential: Get the default {DisplayMarkerLayer}. - # - # All marker APIs not tied to an explicit layer interact with this default - # layer. - # - # Returns a {DisplayMarkerLayer}. - getDefaultMarkerLayer: -> - @defaultMarkerLayer - - ### - Section: Cursors - ### - - # Essential: Get the position of the most recently added cursor in buffer - # coordinates. - # - # Returns a {Point} - getCursorBufferPosition: -> - @getLastCursor().getBufferPosition() - - # Essential: Get the position of all the cursor positions in buffer coordinates. - # - # Returns {Array} of {Point}s in the order they were added - getCursorBufferPositions: -> - cursor.getBufferPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in buffer coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} containing the following keys: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorBufferPosition: (position, options) -> - @moveCursors (cursor) -> cursor.setBufferPosition(position, options) - - # Essential: Get a {Cursor} at given screen coordinates {Point} - # - # * `position` A {Point} or {Array} of `[row, column]` - # - # Returns the first matched {Cursor} or undefined - getCursorAtScreenPosition: (position) -> - if selection = @getSelectionAtScreenPosition(position) - if selection.getHeadScreenPosition().isEqual(position) - selection.cursor - - # Essential: Get the position of the most recently added cursor in screen - # coordinates. - # - # Returns a {Point}. - getCursorScreenPosition: -> - @getLastCursor().getScreenPosition() - - # Essential: Get the position of all the cursor positions in screen coordinates. - # - # Returns {Array} of {Point}s in the order the cursors were added - getCursorScreenPositions: -> - cursor.getScreenPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in screen coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorScreenPosition: (position, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @moveCursors (cursor) -> cursor.setScreenPosition(position, options) - - # Essential: Add a cursor at the given position in buffer coordinates. - # - # * `bufferPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtBufferPosition: (bufferPosition, options) -> - @selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Add a cursor at the position in screen coordinates. - # - # * `screenPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtScreenPosition: (screenPosition, options) -> - @selectionsMarkerLayer.markScreenPosition(screenPosition, {invalidate: 'never'}) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Returns {Boolean} indicating whether or not there are multiple cursors. - hasMultipleCursors: -> - @getCursors().length > 1 - - # Essential: Move every cursor up one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveUp: (lineCount) -> - @moveCursors (cursor) -> cursor.moveUp(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor down one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveDown: (lineCount) -> - @moveCursors (cursor) -> cursor.moveDown(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor left one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveLeft: (columnCount) -> - @moveCursors (cursor) -> cursor.moveLeft(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor right one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveRight: (columnCount) -> - @moveCursors (cursor) -> cursor.moveRight(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor to the beginning of its line in buffer coordinates. - moveToBeginningOfLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfLine() - - # Essential: Move every cursor to the beginning of its line in screen coordinates. - moveToBeginningOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfScreenLine() - - # Essential: Move every cursor to the first non-whitespace character of its line. - moveToFirstCharacterOfLine: -> - @moveCursors (cursor) -> cursor.moveToFirstCharacterOfLine() - - # Essential: Move every cursor to the end of its line in buffer coordinates. - moveToEndOfLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfLine() - - # Essential: Move every cursor to the end of its line in screen coordinates. - moveToEndOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfScreenLine() - - # Essential: Move every cursor to the beginning of its surrounding word. - moveToBeginningOfWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfWord() - - # Essential: Move every cursor to the end of its surrounding word. - moveToEndOfWord: -> - @moveCursors (cursor) -> cursor.moveToEndOfWord() - - # Cursor Extended - - # Extended: Move every cursor to the top of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToTop: -> - @moveCursors (cursor) -> cursor.moveToTop() - - # Extended: Move every cursor to the bottom of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToBottom: -> - @moveCursors (cursor) -> cursor.moveToBottom() - - # Extended: Move every cursor to the beginning of the next word. - moveToBeginningOfNextWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextWord() - - # Extended: Move every cursor to the previous word boundary. - moveToPreviousWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToPreviousWordBoundary() - - # Extended: Move every cursor to the next word boundary. - moveToNextWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToNextWordBoundary() - - # Extended: Move every cursor to the previous subword boundary. - moveToPreviousSubwordBoundary: -> - @moveCursors (cursor) -> cursor.moveToPreviousSubwordBoundary() - - # Extended: Move every cursor to the next subword boundary. - moveToNextSubwordBoundary: -> - @moveCursors (cursor) -> cursor.moveToNextSubwordBoundary() - - # Extended: Move every cursor to the beginning of the next paragraph. - moveToBeginningOfNextParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextParagraph() - - # Extended: Move every cursor to the beginning of the previous paragraph. - moveToBeginningOfPreviousParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfPreviousParagraph() - - # Extended: Returns the most recently added {Cursor} - getLastCursor: -> - @createLastSelectionIfNeeded() - _.last(@cursors) - - # Extended: Returns the word surrounding the most recently added cursor. - # - # * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. - getWordUnderCursor: (options) -> - @getTextInBufferRange(@getLastCursor().getCurrentWordBufferRange(options)) - - # Extended: Get an Array of all {Cursor}s. - getCursors: -> - @createLastSelectionIfNeeded() - @cursors.slice() - - # Extended: Get all {Cursors}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getCursorsOrderedByBufferPosition: -> - @getCursors().sort (a, b) -> a.compare(b) - - cursorsForScreenRowRange: (startScreenRow, endScreenRow) -> - cursors = [] - for marker in @selectionsMarkerLayer.findMarkers(intersectsScreenRowRange: [startScreenRow, endScreenRow]) - if cursor = @cursorsByMarkerId.get(marker.id) - cursors.push(cursor) - cursors - - # Add a cursor based on the given {DisplayMarker}. - addCursor: (marker) -> - cursor = new Cursor(editor: this, marker: marker, showCursorOnSelection: @showCursorOnSelection) - @cursors.push(cursor) - @cursorsByMarkerId.set(marker.id, cursor) - cursor - - moveCursors: (fn) -> - @transact => - fn(cursor) for cursor in @getCursors() - @mergeCursors() - - cursorMoved: (event) -> - @emitter.emit 'did-change-cursor-position', event - - # Merge cursors that have the same screen position - mergeCursors: -> - positions = {} - for cursor in @getCursors() - position = cursor.getBufferPosition().toString() - if positions.hasOwnProperty(position) - cursor.destroy() - else - positions[position] = true - return - - ### - Section: Selections - ### - - # Essential: Get the selected text of the most recently added selection. - # - # Returns a {String}. - getSelectedText: -> - @getLastSelection().getText() - - # Essential: Get the {Range} of the most recently added selection in buffer - # coordinates. - # - # Returns a {Range}. - getSelectedBufferRange: -> - @getLastSelection().getBufferRange() - - # Essential: Get the {Range}s of all selections in buffer coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedBufferRanges: -> - selection.getBufferRange() for selection in @getSelections() - - # Essential: Set the selected range in buffer coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - setSelectedBufferRange: (bufferRange, options) -> - @setSelectedBufferRanges([bufferRange], options) - - # Essential: Set the selected ranges in buffer coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - setSelectedBufferRanges: (bufferRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedBufferRanges") unless bufferRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[bufferRanges.length...] - - @mergeIntersectingSelections options, => - for bufferRange, i in bufferRanges - bufferRange = Range.fromObject(bufferRange) - if selections[i] - selections[i].setBufferRange(bufferRange, options) - else - @addSelectionForBufferRange(bufferRange, options) - return - - # Essential: Get the {Range} of the most recently added selection in screen - # coordinates. - # - # Returns a {Range}. - getSelectedScreenRange: -> - @getLastSelection().getScreenRange() - - # Essential: Get the {Range}s of all selections in screen coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedScreenRanges: -> - selection.getScreenRange() for selection in @getSelections() - - # Essential: Set the selected range in screen coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `screenRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRange: (screenRange, options) -> - @setSelectedBufferRange(@bufferRangeForScreenRange(screenRange, options), options) - - # Essential: Set the selected ranges in screen coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRanges: (screenRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedScreenRanges") unless screenRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[screenRanges.length...] - - @mergeIntersectingSelections options, => - for screenRange, i in screenRanges - screenRange = Range.fromObject(screenRange) - if selections[i] - selections[i].setScreenRange(screenRange, options) - else - @addSelectionForScreenRange(screenRange, options) - return - - # Essential: Add a selection for the given range in buffer coordinates. - # - # * `bufferRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - # - # Returns the added {Selection}. - addSelectionForBufferRange: (bufferRange, options={}) -> - unless options.preserveFolds - @destroyFoldsIntersectingBufferRange(bufferRange) - @selectionsMarkerLayer.markBufferRange(bufferRange, {invalidate: 'never', reversed: options.reversed ? false}) - @getLastSelection().autoscroll() unless options.autoscroll is false - @getLastSelection() - - # Essential: Add a selection for the given range in screen coordinates. - # - # * `screenRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - # Returns the added {Selection}. - addSelectionForScreenRange: (screenRange, options={}) -> - @addSelectionForBufferRange(@bufferRangeForScreenRange(screenRange), options) - - # Essential: Select from the current cursor position to the given position in - # buffer coordinates. - # - # This method may merge selections that end up intersecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToBufferPosition: (position) -> - lastSelection = @getLastSelection() - lastSelection.selectToBufferPosition(position) - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Select from the current cursor position to the given position in - # screen coordinates. - # - # This method may merge selections that end up intersecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToScreenPosition: (position, options) -> - lastSelection = @getLastSelection() - lastSelection.selectToScreenPosition(position, options) - unless options?.suppressSelectionMerge - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Move the cursor of each selection one character upward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectUp: (rowCount) -> - @expandSelectionsBackward (selection) -> selection.selectUp(rowCount) - - # Essential: Move the cursor of each selection one character downward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectDown: (rowCount) -> - @expandSelectionsForward (selection) -> selection.selectDown(rowCount) - - # Essential: Move the cursor of each selection one character leftward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectLeft: (columnCount) -> - @expandSelectionsBackward (selection) -> selection.selectLeft(columnCount) - - # Essential: Move the cursor of each selection one character rightward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectRight: (columnCount) -> - @expandSelectionsForward (selection) -> selection.selectRight(columnCount) - - # Essential: Select from the top of the buffer to the end of the last selection - # in the buffer. - # - # This method merges multiple selections into a single selection. - selectToTop: -> - @expandSelectionsBackward (selection) -> selection.selectToTop() - - # Essential: Selects from the top of the first selection in the buffer to the end - # of the buffer. - # - # This method merges multiple selections into a single selection. - selectToBottom: -> - @expandSelectionsForward (selection) -> selection.selectToBottom() - - # Essential: Select all text in the buffer. - # - # This method merges multiple selections into a single selection. - selectAll: -> - @expandSelectionsForward (selection) -> selection.selectAll() - - # Essential: Move the cursor of each selection to the beginning of its line - # while preserving the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToBeginningOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfLine() - - # Essential: Move the cursor of each selection to the first non-whitespace - # character of its line while preserving the selection's tail position. If the - # cursor is already on the first character of the line, move it to the - # beginning of the line. - # - # This method may merge selections that end up intersecting. - selectToFirstCharacterOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToFirstCharacterOfLine() - - # Essential: Move the cursor of each selection to the end of its line while - # preserving the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToEndOfLine: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfLine() - - # Essential: Expand selections to the beginning of their containing word. - # - # Operates on all selections. Moves the cursor to the beginning of the - # containing word while preserving the selection's tail position. - selectToBeginningOfWord: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfWord() - - # Essential: Expand selections to the end of their containing word. - # - # Operates on all selections. Moves the cursor to the end of the containing - # word while preserving the selection's tail position. - selectToEndOfWord: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfWord() - - # Extended: For each selection, move its cursor to the preceding subword - # boundary while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToPreviousSubwordBoundary: -> - @expandSelectionsBackward (selection) -> selection.selectToPreviousSubwordBoundary() - - # Extended: For each selection, move its cursor to the next subword boundary - # while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToNextSubwordBoundary: -> - @expandSelectionsForward (selection) -> selection.selectToNextSubwordBoundary() - - # Essential: For each cursor, select the containing line. - # - # This method merges selections on successive lines. - selectLinesContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectLine() - - # Essential: Select the word surrounding each cursor. - selectWordsContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectWord() - - # Selection Extended - - # Extended: For each selection, move its cursor to the preceding word boundary - # while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToPreviousWordBoundary: -> - @expandSelectionsBackward (selection) -> selection.selectToPreviousWordBoundary() - - # Extended: For each selection, move its cursor to the next word boundary while - # maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToNextWordBoundary: -> - @expandSelectionsForward (selection) -> selection.selectToNextWordBoundary() - - # Extended: Expand selections to the beginning of the next word. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # word while preserving the selection's tail position. - selectToBeginningOfNextWord: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextWord() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfNextParagraph: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextParagraph() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfPreviousParagraph: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfPreviousParagraph() - - # Extended: Select the range of the given marker if it is valid. - # - # * `marker` A {DisplayMarker} - # - # Returns the selected {Range} or `undefined` if the marker is invalid. - selectMarker: (marker) -> - if marker.isValid() - range = marker.getBufferRange() - @setSelectedBufferRange(range) - range - - # Extended: Get the most recently added {Selection}. - # - # Returns a {Selection}. - getLastSelection: -> - @createLastSelectionIfNeeded() - _.last(@selections) - - getSelectionAtScreenPosition: (position) -> - markers = @selectionsMarkerLayer.findMarkers(containsScreenPosition: position) - if markers.length > 0 - @cursorsByMarkerId.get(markers[0].id).selection - - # Extended: Get current {Selection}s. - # - # Returns: An {Array} of {Selection}s. - getSelections: -> - @createLastSelectionIfNeeded() - @selections.slice() - - # Extended: Get all {Selection}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getSelectionsOrderedByBufferPosition: -> - @getSelections().sort (a, b) -> a.compare(b) - - # Extended: Determine if a given range in buffer coordinates intersects a - # selection. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # - # Returns a {Boolean}. - selectionIntersectsBufferRange: (bufferRange) -> - _.any @getSelections(), (selection) -> - selection.intersectsBufferRange(bufferRange) - - # Selections Private - - # Add a similarly-shaped selection to the next eligible line below - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next following non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionBelow: -> - @expandSelectionsForward (selection) -> selection.addSelectionBelow() - - # Add a similarly-shaped selection to the next eligible line above - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next preceding non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionAbove: -> - @expandSelectionsBackward (selection) -> selection.addSelectionAbove() - - # Calls the given function with each selection, then merges selections - expandSelectionsForward: (fn) -> - @mergeIntersectingSelections => - fn(selection) for selection in @getSelections() - return - - # Calls the given function with each selection, then merges selections in the - # reversed orientation - expandSelectionsBackward: (fn) -> - @mergeIntersectingSelections reversed: true, => - fn(selection) for selection in @getSelections() - return - - finalizeSelections: -> - selection.finalize() for selection in @getSelections() - return - - selectionsForScreenRows: (startRow, endRow) -> - @getSelections().filter (selection) -> selection.intersectsScreenRowRange(startRow, endRow) - - # Merges intersecting selections. If passed a function, it executes - # the function with merging suppressed, then merges intersecting selections - # afterward. - mergeIntersectingSelections: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - exclusive = not currentSelection.isEmpty() and not previousSelection.isEmpty() - - previousSelection.intersectsWith(currentSelection, exclusive) - - mergeSelectionsOnSameRows: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - screenRange = currentSelection.getScreenRange() - - previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) - - avoidMergingSelections: (args...) -> - @mergeSelections args..., -> false - - mergeSelections: (args...) -> - mergePredicate = args.pop() - fn = args.pop() if _.isFunction(_.last(args)) - options = args.pop() ? {} - - return fn?() if @suppressSelectionMerging - - if fn? - @suppressSelectionMerging = true - result = fn() - @suppressSelectionMerging = false - - reducer = (disjointSelections, selection) -> - adjacentSelection = _.last(disjointSelections) - if mergePredicate(adjacentSelection, selection) - adjacentSelection.merge(selection, options) - disjointSelections - else - disjointSelections.concat([selection]) - - [head, tail...] = @getSelectionsOrderedByBufferPosition() - _.reduce(tail, reducer, [head]) - return result if fn? - - # Add a {Selection} based on the given {DisplayMarker}. - # - # * `marker` The {DisplayMarker} to highlight - # * `options` (optional) An {Object} that pertains to the {Selection} constructor. - # - # Returns the new {Selection}. - addSelection: (marker, options={}) -> - cursor = @addCursor(marker) - selection = new Selection(Object.assign({editor: this, marker, cursor}, options)) - @selections.push(selection) - selectionBufferRange = selection.getBufferRange() - @mergeIntersectingSelections(preserveFolds: options.preserveFolds) - - if selection.destroyed - for selection in @getSelections() - if selection.intersectsBufferRange(selectionBufferRange) - return selection - else - @emitter.emit 'did-add-cursor', cursor - @emitter.emit 'did-add-selection', selection - selection - - # Remove the given selection. - removeSelection: (selection) -> - _.remove(@cursors, selection.cursor) - _.remove(@selections, selection) - @cursorsByMarkerId.delete(selection.cursor.marker.id) - @emitter.emit 'did-remove-cursor', selection.cursor - @emitter.emit 'did-remove-selection', selection - - # Reduce one or more selections to a single empty selection based on the most - # recently added cursor. - clearSelections: (options) -> - @consolidateSelections() - @getLastSelection().clear(options) - - # Reduce multiple selections to the least recently added selection. - consolidateSelections: -> - selections = @getSelections() - if selections.length > 1 - selection.destroy() for selection in selections[1...(selections.length)] - selections[0].autoscroll(center: true) - true - else - false - - # Called by the selection - selectionRangeChanged: (event) -> - @component?.didChangeSelectionRange() - @emitter.emit 'did-change-selection-range', event - - createLastSelectionIfNeeded: -> - if @selections.length is 0 - @addSelectionForBufferRange([[0, 0], [0, 0]], autoscroll: false, preserveFolds: true) - - ### - Section: Searching and Replacing - ### - - # Essential: Scan regular expression matches in the entire buffer, calling the - # given iterator function on each match. - # - # `::scan` functions as the replace method as well via the `replace` - # - # If you're programmatically modifying the results, you may want to try - # {::backwardsScanInBufferRange} to avoid tripping over your own changes. - # - # * `regex` A {RegExp} to search for. - # * `options` (optional) {Object} - # * `leadingContextLineCount` {Number} default `0`; The number of lines - # before the matched line to include in the results object. - # * `trailingContextLineCount` {Number} default `0`; The number of lines - # after the matched line to include in the results object. - # * `iterator` A {Function} that's called on each match - # * `object` {Object} - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scan: (regex, options={}, iterator) -> - if _.isFunction(options) - iterator = options - options = {} - - @buffer.scan(regex, options, iterator) - - # Essential: Scan regular expression matches in a given range, calling the given - # iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scanInBufferRange: (regex, range, iterator) -> @buffer.scanInRange(regex, range, iterator) - - # Essential: Scan regular expression matches in a given range in reverse order, - # calling the given iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - backwardsScanInBufferRange: (regex, range, iterator) -> @buffer.backwardsScanInRange(regex, range, iterator) - - ### - Section: Tab Behavior - ### - - # Essential: Returns a {Boolean} indicating whether softTabs are enabled for this - # editor. - getSoftTabs: -> @softTabs - - # Essential: Enable or disable soft tabs for this editor. - # - # * `softTabs` A {Boolean} - setSoftTabs: (@softTabs) -> @update({@softTabs}) - - # Returns a {Boolean} indicating whether atomic soft tabs are enabled for this editor. - hasAtomicSoftTabs: -> @displayLayer.atomicSoftTabs - - # Essential: Toggle soft tabs for this editor - toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs()) - - # Essential: Get the on-screen length of tab characters. - # - # Returns a {Number}. - getTabLength: -> @tokenizedBuffer.getTabLength() - - # Essential: Set the on-screen length of tab characters. Setting this to a - # {Number} This will override the `editor.tabLength` setting. - # - # * `tabLength` {Number} length of a single tab. Setting to `null` will - # fallback to using the `editor.tabLength` config setting - setTabLength: (tabLength) -> @update({tabLength}) - - # Returns an {Object} representing the current invisible character - # substitutions for this editor. See {::setInvisibles}. - getInvisibles: -> - if not @mini and @showInvisibles and @invisibles? - @invisibles - else - {} - - doesShowIndentGuide: -> @showIndentGuide and not @mini - - getSoftWrapHangingIndentLength: -> @displayLayer.softWrapHangingIndent - - # Extended: Determine if the buffer uses hard or soft tabs. - # - # Returns `true` if the first non-comment line with leading whitespace starts - # with a space character. Returns `false` if it starts with a hard tab (`\t`). - # - # Returns a {Boolean} or undefined if no non-comment lines had leading - # whitespace. - usesSoftTabs: -> - for bufferRow in [0..Math.min(1000, @buffer.getLastRow())] - continue if @tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() - - line = @buffer.lineForRow(bufferRow) - return true if line[0] is ' ' - return false if line[0] is '\t' - - undefined - - # Extended: Get the text representing a single level of indent. - # - # If soft tabs are enabled, the text is composed of N spaces, where N is the - # tab length. Otherwise the text is a tab character (`\t`). - # - # Returns a {String}. - getTabText: -> @buildIndentString(1) - - # If soft tabs are enabled, convert all hard tabs to soft tabs in the given - # {Range}. - normalizeTabsInBufferRange: (bufferRange) -> - return unless @getSoftTabs() - @scanInBufferRange /\t/g, bufferRange, ({replace}) => replace(@getTabText()) - - ### - Section: Soft Wrap Behavior - ### - - # Essential: Determine whether lines in this editor are soft-wrapped. - # - # Returns a {Boolean}. - isSoftWrapped: -> @softWrapped - - # Essential: Enable or disable soft wrapping for this editor. - # - # * `softWrapped` A {Boolean} - # - # Returns a {Boolean}. - setSoftWrapped: (softWrapped) -> - @update({softWrapped}) - @isSoftWrapped() - - getPreferredLineLength: -> @preferredLineLength - - # Essential: Toggle soft wrapping for this editor - # - # Returns a {Boolean}. - toggleSoftWrapped: -> @setSoftWrapped(not @isSoftWrapped()) - - # Essential: Gets the column at which column will soft wrap - getSoftWrapColumn: -> - if @isSoftWrapped() and not @mini - if @softWrapAtPreferredLineLength - Math.min(@getEditorWidthInChars(), @preferredLineLength) - else - @getEditorWidthInChars() - else - @maxScreenLineLength - - ### - Section: Indentation - ### - - # Essential: Get the indentation level of the given buffer row. - # - # Determines how deeply the given row is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # - # Returns a {Number}. - indentationForBufferRow: (bufferRow) -> - @indentLevelForLine(@lineTextForBufferRow(bufferRow)) - - # Essential: Set the indentation level for the given buffer row. - # - # Inserts or removes hard tabs or spaces based on the soft tabs and tab length - # settings of this editor in order to bring it to the given indentation level. - # Note that if soft tabs are enabled and the tab length is 2, a row with 4 - # leading spaces would have an indentation level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # * `newLevel` A {Number} indicating the new indentation level. - # * `options` (optional) An {Object} with the following keys: - # * `preserveLeadingWhitespace` `true` to preserve any whitespace already at - # the beginning of the line (default: false). - setIndentationForBufferRow: (bufferRow, newLevel, {preserveLeadingWhitespace}={}) -> - if preserveLeadingWhitespace - endColumn = 0 - else - endColumn = @lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length - newIndentString = @buildIndentString(newLevel) - @buffer.setTextInRange([[bufferRow, 0], [bufferRow, endColumn]], newIndentString) - - # Extended: Indent rows intersecting selections by one level. - indentSelectedRows: -> - @mutateSelectedText (selection) -> selection.indentSelectedRows() - - # Extended: Outdent rows intersecting selections by one level. - outdentSelectedRows: -> - @mutateSelectedText (selection) -> selection.outdentSelectedRows() - - # Extended: Get the indentation level of the given line of text. - # - # Determines how deeply the given line is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `line` A {String} representing a line of text. - # - # Returns a {Number}. - indentLevelForLine: (line) -> - @tokenizedBuffer.indentLevelForLine(line) - - # Extended: Indent rows intersecting selections based on the grammar's suggested - # indent level. - autoIndentSelectedRows: -> - @mutateSelectedText (selection) -> selection.autoIndentSelectedRows() - - # Indent all lines intersecting selections. See {Selection::indent} for more - # information. - indent: (options={}) -> - options.autoIndent ?= @shouldAutoIndent() - @mutateSelectedText (selection) -> selection.indent(options) - - # Constructs the string used for indents. - buildIndentString: (level, column=0) -> - if @getSoftTabs() - tabStopViolation = column % @getTabLength() - _.multiplyString(" ", Math.floor(level * @getTabLength()) - tabStopViolation) - else - excessWhitespace = _.multiplyString(' ', Math.round((level - Math.floor(level)) * @getTabLength())) - _.multiplyString("\t", Math.floor(level)) + excessWhitespace - - ### - Section: Grammars - ### - - # Essential: Get the current {Grammar} of this editor. - getGrammar: -> - @tokenizedBuffer.grammar - - # Essential: Set the current {Grammar} of this editor. - # - # Assigning a grammar will cause the editor to re-tokenize based on the new - # grammar. - # - # * `grammar` {Grammar} - setGrammar: (grammar) -> - @tokenizedBuffer.setGrammar(grammar) - - # Reload the grammar based on the file name. - reloadGrammar: -> - @tokenizedBuffer.reloadGrammar() - - # Experimental: Get a notification when async tokenization is completed. - onDidTokenize: (callback) -> - @tokenizedBuffer.onDidTokenize(callback) - - ### - Section: Managing Syntax Scopes - ### - - # Essential: Returns a {ScopeDescriptor} that includes this editor's language. - # e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with - # {Config::get} to get language specific config values. - getRootScopeDescriptor: -> - @tokenizedBuffer.rootScopeDescriptor - - # Essential: Get the syntactic scopeDescriptor for the given position in buffer - # coordinates. Useful with {Config::get}. - # - # For example, if called with a position inside the parameter list of an - # anonymous CoffeeScript function, the method returns the following array: - # `["source.coffee", "meta.inline.function.coffee", "variable.parameter.function.coffee"]` - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # - # Returns a {ScopeDescriptor}. - scopeDescriptorForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) - - # Extended: Get the range in buffer coordinates of all tokens surrounding the - # cursor that match the given scope selector. - # - # For example, if you wanted to find the string surrounding the cursor, you - # could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. - # - # * `scopeSelector` {String} selector. e.g. `'.source.ruby'` - # - # Returns a {Range}. - bufferRangeForScopeAtCursor: (scopeSelector) -> - @bufferRangeForScopeAtPosition(scopeSelector, @getCursorBufferPosition()) - - bufferRangeForScopeAtPosition: (scopeSelector, position) -> - @tokenizedBuffer.bufferRangeForScopeAtPosition(scopeSelector, position) - - # Extended: Determine if the given row is entirely a comment - isBufferRowCommented: (bufferRow) -> - if match = @lineTextForBufferRow(bufferRow).match(/\S/) - @commentScopeSelector ?= new TextMateScopeSelector('comment.*') - @commentScopeSelector.matches(@scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) - - # Get the scope descriptor at the cursor. - getCursorScope: -> - @getLastCursor().getScopeDescriptor() - - tokenForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.tokenForPosition(bufferPosition) - - ### - Section: Clipboard Operations - ### - - # Essential: For each selection, copy the selected text. - copySelectedText: -> - maintainClipboard = false - for selection in @getSelectionsOrderedByBufferPosition() - if selection.isEmpty() - previousRange = selection.getBufferRange() - selection.selectLine() - selection.copy(maintainClipboard, true) - selection.setBufferRange(previousRange) - else - selection.copy(maintainClipboard, false) - maintainClipboard = true - return - - # Private: For each selection, only copy highlighted text. - copyOnlySelectedText: -> - maintainClipboard = false - for selection in @getSelectionsOrderedByBufferPosition() - if not selection.isEmpty() - selection.copy(maintainClipboard, false) - maintainClipboard = true - return - - # Essential: For each selection, cut the selected text. - cutSelectedText: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectLine() - selection.cut(maintainClipboard, true) - else - selection.cut(maintainClipboard, false) - maintainClipboard = true - - # Essential: For each selection, replace the selected text with the contents of - # the clipboard. - # - # If the clipboard contains the same number of selections as the current - # editor, each selection will be replaced with the content of the - # corresponding clipboard selection text. - # - # * `options` (optional) See {Selection::insertText}. - pasteText: (options={}) -> - {text: clipboardText, metadata} = @constructor.clipboard.readWithMetadata() - return false unless @emitWillInsertTextEvent(clipboardText) - - metadata ?= {} - options.autoIndent = @shouldAutoIndentOnPaste() - - @mutateSelectedText (selection, index) => - if metadata.selections?.length is @getSelections().length - {text, indentBasis, fullLine} = metadata.selections[index] - else - {indentBasis, fullLine} = metadata - text = clipboardText - - delete options.indentBasis - {cursor} = selection - if indentBasis? - containsNewlines = text.indexOf('\n') isnt -1 - if containsNewlines or not cursor.hasPrecedingCharactersOnLine() - options.indentBasis ?= indentBasis - - range = null - if fullLine and selection.isEmpty() - oldPosition = selection.getBufferRange().start - selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]) - range = selection.insertText(text, options) - newPosition = oldPosition.translate([1, 0]) - selection.setBufferRange([newPosition, newPosition]) - else - range = selection.insertText(text, options) - - didInsertEvent = {text, range} - @emitter.emit 'did-insert-text', didInsertEvent - - # Essential: For each selection, if the selection is empty, cut all characters - # of the containing screen line following the cursor. Otherwise cut the selected - # text. - cutToEndOfLine: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - selection.cutToEndOfLine(maintainClipboard) - maintainClipboard = true - - # Essential: For each selection, if the selection is empty, cut all characters - # of the containing buffer line following the cursor. Otherwise cut the - # selected text. - cutToEndOfBufferLine: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - selection.cutToEndOfBufferLine(maintainClipboard) - maintainClipboard = true - - ### - Section: Folds - ### - - # Essential: Fold the most recent cursor's row based on its indentation level. - # - # The fold will extend from the nearest preceding line with a lower - # indentation level up to the nearest following row with a lower indentation - # level. - foldCurrentRow: -> - {row} = @getCursorBufferPosition() - range = @tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) - @displayLayer.foldBufferRange(range) - - # Essential: Unfold the most recent cursor's row by one level. - unfoldCurrentRow: -> - {row} = @getCursorBufferPosition() - position = Point(row, Infinity) - @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) - - # Essential: Fold the given row in buffer coordinates based on its indentation - # level. - # - # If the given row is foldable, the fold will begin there. Otherwise, it will - # begin at the first foldable row preceding the given row. - # - # * `bufferRow` A {Number}. - foldBufferRow: (bufferRow) -> - position = Point(bufferRow, Infinity) - loop - foldableRange = @tokenizedBuffer.getFoldableRangeContainingPoint(position, @getTabLength()) - if foldableRange - existingFolds = @displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) - if existingFolds.length is 0 - @displayLayer.foldBufferRange(foldableRange) - else - firstExistingFoldRange = @displayLayer.bufferRangeForFold(existingFolds[0]) - if firstExistingFoldRange.start.isLessThan(position) - position = Point(firstExistingFoldRange.start.row, 0) - continue - return - - # Essential: Unfold all folds containing the given row in buffer coordinates. - # - # * `bufferRow` A {Number} - unfoldBufferRow: (bufferRow) -> - position = Point(bufferRow, Infinity) - @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) - - # Extended: For each selection, fold the rows it intersects. - foldSelectedLines: -> - selection.fold() for selection in @getSelections() - return - - # Extended: Fold all foldable lines. - foldAll: -> - @displayLayer.destroyAllFolds() - for range in @tokenizedBuffer.getFoldableRanges(@getTabLength()) - @displayLayer.foldBufferRange(range) - return - - # Extended: Unfold all existing folds. - unfoldAll: -> - result = @displayLayer.destroyAllFolds() - @scrollToCursorPosition() - result - - # Extended: Fold all foldable lines at the given indent level. - # - # * `level` A {Number}. - foldAllAtIndentLevel: (level) -> - @displayLayer.destroyAllFolds() - for range in @tokenizedBuffer.getFoldableRangesAtIndentLevel(level, @getTabLength()) - @displayLayer.foldBufferRange(range) - return - - # Extended: Determine whether the given row in buffer coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtBufferRow: (bufferRow) -> - @tokenizedBuffer.isFoldableAtRow(bufferRow) - - # Extended: Determine whether the given row in screen coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtScreenRow: (screenRow) -> - @isFoldableAtBufferRow(@bufferRowForScreenRow(screenRow)) - - # Extended: Fold the given buffer row if it isn't currently folded, and unfold - # it otherwise. - toggleFoldAtBufferRow: (bufferRow) -> - if @isFoldedAtBufferRow(bufferRow) - @unfoldBufferRow(bufferRow) - else - @foldBufferRow(bufferRow) - - # Extended: Determine whether the most recently added cursor's row is folded. - # - # Returns a {Boolean}. - isFoldedAtCursorRow: -> - @isFoldedAtBufferRow(@getCursorBufferPosition().row) - - # Extended: Determine whether the given row in buffer coordinates is folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtBufferRow: (bufferRow) -> - range = Range( - Point(bufferRow, 0), - Point(bufferRow, @buffer.lineLengthForRow(bufferRow)) - ) - @displayLayer.foldsIntersectingBufferRange(range).length > 0 - - # Extended: Determine whether the given row in screen coordinates is folded. - # - # * `screenRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtScreenRow: (screenRow) -> - @isFoldedAtBufferRow(@bufferRowForScreenRow(screenRow)) - - # Creates a new fold between two row numbers. - # - # startRow - The row {Number} to start folding at - # endRow - The row {Number} to end the fold - # - # Returns the new {Fold}. - foldBufferRowRange: (startRow, endRow) -> - @foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) - - foldBufferRange: (range) -> - @displayLayer.foldBufferRange(range) - - # Remove any {Fold}s found that intersect the given buffer range. - destroyFoldsIntersectingBufferRange: (bufferRange) -> - @displayLayer.destroyFoldsIntersectingBufferRange(bufferRange) - - ### - Section: Gutters - ### - - # Essential: Add a custom {Gutter}. - # - # * `options` An {Object} with the following fields: - # * `name` (required) A unique {String} to identify this gutter. - # * `priority` (optional) A {Number} that determines stacking order between - # gutters. Lower priority items are forced closer to the edges of the - # window. (default: -100) - # * `visible` (optional) {Boolean} specifying whether the gutter is visible - # initially after being created. (default: true) - # - # Returns the newly-created {Gutter}. - addGutter: (options) -> - @gutterContainer.addGutter(options) - - # Essential: Get this editor's gutters. - # - # Returns an {Array} of {Gutter}s. - getGutters: -> - @gutterContainer.getGutters() - - getLineNumberGutter: -> - @lineNumberGutter - - # Essential: Get the gutter with the given name. - # - # Returns a {Gutter}, or `null` if no gutter exists for the given name. - gutterWithName: (name) -> - @gutterContainer.gutterWithName(name) - - ### - Section: Scrolling the TextEditor - ### - - # Essential: Scroll the editor to reveal the most recently added cursor if it is - # off-screen. - # - # * `options` (optional) {Object} - # * `center` Center the editor around the cursor if possible. (default: true) - scrollToCursorPosition: (options) -> - @getLastCursor().autoscroll(center: options?.center ? true) - - # Essential: Scrolls the editor to the given buffer position. - # - # * `bufferPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToBufferPosition: (bufferPosition, options) -> - @scrollToScreenPosition(@screenPositionForBufferPosition(bufferPosition), options) - - # Essential: Scrolls the editor to the given screen position. - # - # * `screenPosition` An object that represents a screen position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToScreenPosition: (screenPosition, options) -> - @scrollToScreenRange(new Range(screenPosition, screenPosition), options) - - scrollToTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::scrollToTop instead.") - - @getElement().scrollToTop() - - scrollToBottom: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::scrollToTop instead.") - - @getElement().scrollToBottom() - - scrollToScreenRange: (screenRange, options = {}) -> - screenRange = @clipScreenRange(screenRange) if options.clip isnt false - scrollEvent = {screenRange, options} - @component?.didRequestAutoscroll(scrollEvent) - @emitter.emit "did-request-autoscroll", scrollEvent - - getHorizontalScrollbarHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getHorizontalScrollbarHeight instead.") - - @getElement().getHorizontalScrollbarHeight() - - getVerticalScrollbarWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getVerticalScrollbarWidth instead.") - - @getElement().getVerticalScrollbarWidth() - - pageUp: -> - @moveUp(@getRowsPerPage()) - - pageDown: -> - @moveDown(@getRowsPerPage()) - - selectPageUp: -> - @selectUp(@getRowsPerPage()) - - selectPageDown: -> - @selectDown(@getRowsPerPage()) - - # Returns the number of rows per page - getRowsPerPage: -> - if @component? - clientHeight = @component.getScrollContainerClientHeight() - lineHeight = @component.getLineHeight() - Math.max(1, Math.ceil(clientHeight / lineHeight)) - else - 1 - - Object.defineProperty(@prototype, 'rowsPerPage', { - get: -> @getRowsPerPage() - }) - - ### - Section: Config - ### - - # Experimental: Supply an object that will provide the editor with settings - # for specific syntactic scopes. See the `ScopedSettingsDelegate` in - # `text-editor-registry.js` for an example implementation. - setScopedSettingsDelegate: (@scopedSettingsDelegate) -> - @tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate - - # Experimental: Retrieve the {Object} that provides the editor with settings - # for specific syntactic scopes. - getScopedSettingsDelegate: -> @scopedSettingsDelegate - - # Experimental: Is auto-indentation enabled for this editor? - # - # Returns a {Boolean}. - shouldAutoIndent: -> @autoIndent - - # Experimental: Is auto-indentation on paste enabled for this editor? - # - # Returns a {Boolean}. - shouldAutoIndentOnPaste: -> @autoIndentOnPaste - - # Experimental: Does this editor allow scrolling past the last line? - # - # Returns a {Boolean}. - getScrollPastEnd: -> - if @getAutoHeight() - false - else - @scrollPastEnd - - # Experimental: How fast does the editor scroll in response to mouse wheel - # movements? - # - # Returns a positive {Number}. - getScrollSensitivity: -> @scrollSensitivity - - # Experimental: Does this editor show cursors while there is a selection? - # - # Returns a positive {Boolean}. - getShowCursorOnSelection: -> @showCursorOnSelection - - # Experimental: Are line numbers enabled for this editor? - # - # Returns a {Boolean} - doesShowLineNumbers: -> @showLineNumbers - - # Experimental: Get the time interval within which text editing operations - # are grouped together in the editor's undo history. - # - # Returns the time interval {Number} in milliseconds. - getUndoGroupingInterval: -> @undoGroupingInterval - - # Experimental: Get the characters that are *not* considered part of words, - # for the purpose of word-based cursor movements. - # - # Returns a {String} containing the non-word characters. - getNonWordCharacters: (scopes) -> - @scopedSettingsDelegate?.getNonWordCharacters?(scopes) ? @nonWordCharacters - - getCommentStrings: (scopes) -> - @scopedSettingsDelegate?.getCommentStrings?(scopes) - - ### - Section: Event Handlers - ### - - handleGrammarChange: -> - @unfoldAll() - @emitter.emit 'did-change-grammar', @getGrammar() - - ### - Section: TextEditor Rendering - ### - - # Get the Element for the editor. - getElement: -> - if @component? - @component.element - else - TextEditorComponent ?= require('./text-editor-component') - TextEditorElement ?= require('./text-editor-element') - new TextEditorComponent({ - model: this, - updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, - @initialScrollTopRow, @initialScrollLeftColumn - }) - @component.element - - getAllowedLocations: -> - ['center'] - - # Essential: Retrieves the greyed out placeholder of a mini editor. - # - # Returns a {String}. - getPlaceholderText: -> @placeholderText - - # Essential: Set the greyed out placeholder of a mini editor. Placeholder text - # will be displayed when the editor has no content. - # - # * `placeholderText` {String} text that is displayed when the editor has no content. - setPlaceholderText: (placeholderText) -> @update({placeholderText}) - - pixelPositionForBufferPosition: (bufferPosition) -> - Grim.deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead") - @getElement().pixelPositionForBufferPosition(bufferPosition) - - pixelPositionForScreenPosition: (screenPosition) -> - Grim.deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead") - @getElement().pixelPositionForScreenPosition(screenPosition) - - getVerticalScrollMargin: -> - maxScrollMargin = Math.floor(((@height / @getLineHeightInPixels()) - 1) / 2) - Math.min(@verticalScrollMargin, maxScrollMargin) - - setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin - - getHorizontalScrollMargin: -> Math.min(@horizontalScrollMargin, Math.floor(((@width / @getDefaultCharWidth()) - 1) / 2)) - setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - - getLineHeightInPixels: -> @lineHeightInPixels - setLineHeightInPixels: (@lineHeightInPixels) -> @lineHeightInPixels - - getKoreanCharWidth: -> @koreanCharWidth - getHalfWidthCharWidth: -> @halfWidthCharWidth - getDoubleWidthCharWidth: -> @doubleWidthCharWidth - getDefaultCharWidth: -> @defaultCharWidth - - ratioForCharacter: (character) -> - if isKoreanCharacter(character) - @getKoreanCharWidth() / @getDefaultCharWidth() - else if isHalfWidthCharacter(character) - @getHalfWidthCharWidth() / @getDefaultCharWidth() - else if isDoubleWidthCharacter(character) - @getDoubleWidthCharWidth() / @getDefaultCharWidth() - else - 1 - - setDefaultCharWidth: (defaultCharWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) -> - doubleWidthCharWidth ?= defaultCharWidth - halfWidthCharWidth ?= defaultCharWidth - koreanCharWidth ?= defaultCharWidth - if defaultCharWidth isnt @defaultCharWidth or doubleWidthCharWidth isnt @doubleWidthCharWidth and halfWidthCharWidth isnt @halfWidthCharWidth and koreanCharWidth isnt @koreanCharWidth - @defaultCharWidth = defaultCharWidth - @doubleWidthCharWidth = doubleWidthCharWidth - @halfWidthCharWidth = halfWidthCharWidth - @koreanCharWidth = koreanCharWidth - if @isSoftWrapped() - @displayLayer.reset({ - softWrapColumn: @getSoftWrapColumn() - }) - defaultCharWidth - - setHeight: (height) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setHeight instead.") - @getElement().setHeight(height) - - getHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getHeight instead.") - @getElement().getHeight() - - getAutoHeight: -> @autoHeight ? true - - getAutoWidth: -> @autoWidth ? false - - setWidth: (width) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setWidth instead.") - @getElement().setWidth(width) - - getWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getWidth instead.") - @getElement().getWidth() - - # Use setScrollTopRow instead of this method - setFirstVisibleScreenRow: (screenRow) -> - @setScrollTopRow(screenRow) - - getFirstVisibleScreenRow: -> - @getElement().component.getFirstVisibleRow() - - getLastVisibleScreenRow: -> - @getElement().component.getLastVisibleRow() - - getVisibleRowRange: -> - [@getFirstVisibleScreenRow(), @getLastVisibleScreenRow()] - - # Use setScrollLeftColumn instead of this method - setFirstVisibleScreenColumn: (column) -> - @setScrollLeftColumn(column) - - getFirstVisibleScreenColumn: -> - @getElement().component.getFirstVisibleColumn() - - getScrollTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollTop instead.") - - @getElement().getScrollTop() - - setScrollTop: (scrollTop) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollTop instead.") - - @getElement().setScrollTop(scrollTop) - - getScrollBottom: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollBottom instead.") - - @getElement().getScrollBottom() - - setScrollBottom: (scrollBottom) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollBottom instead.") - - @getElement().setScrollBottom(scrollBottom) - - getScrollLeft: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollLeft instead.") - - @getElement().getScrollLeft() - - setScrollLeft: (scrollLeft) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollLeft instead.") - - @getElement().setScrollLeft(scrollLeft) - - getScrollRight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollRight instead.") - - @getElement().getScrollRight() - - setScrollRight: (scrollRight) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollRight instead.") - - @getElement().setScrollRight(scrollRight) - - getScrollHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollHeight instead.") - - @getElement().getScrollHeight() - - getScrollWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollWidth instead.") - - @getElement().getScrollWidth() - - getMaxScrollTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getMaxScrollTop instead.") - - @getElement().getMaxScrollTop() - - getScrollTopRow: -> - @getElement().component.getScrollTopRow() - - setScrollTopRow: (scrollTopRow) -> - @getElement().component.setScrollTopRow(scrollTopRow) - - getScrollLeftColumn: -> - @getElement().component.getScrollLeftColumn() - - setScrollLeftColumn: (scrollLeftColumn) -> - @getElement().component.setScrollLeftColumn(scrollLeftColumn) - - intersectsVisibleRowRange: (startRow, endRow) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.") - - @getElement().intersectsVisibleRowRange(startRow, endRow) - - selectionIntersectsVisibleRowRange: (selection) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::selectionIntersectsVisibleRowRange instead.") - - @getElement().selectionIntersectsVisibleRowRange(selection) - - screenPositionForPixelPosition: (pixelPosition) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::screenPositionForPixelPosition instead.") - - @getElement().screenPositionForPixelPosition(pixelPosition) - - pixelRectForScreenRange: (screenRange) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::pixelRectForScreenRange instead.") - - @getElement().pixelRectForScreenRange(screenRange) - - ### - Section: Utility - ### - - inspect: -> - "" - - emitWillInsertTextEvent: (text) -> - result = true - cancel = -> result = false - willInsertEvent = {cancel, text} - @emitter.emit 'will-insert-text', willInsertEvent - result - - ### - Section: Language Mode Delegated Methods - ### - - suggestedIndentForBufferRow: (bufferRow, options) -> @tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) - - # Given a buffer row, indent it. - # - # * bufferRow - The row {Number}. - # * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. - autoIndentBufferRow: (bufferRow, options) -> - indentLevel = @suggestedIndentForBufferRow(bufferRow, options) - @setIndentationForBufferRow(bufferRow, indentLevel, options) - - # Indents all the rows between two buffer row numbers. - # - # * startRow - The row {Number} to start at - # * endRow - The row {Number} to end at - autoIndentBufferRows: (startRow, endRow) -> - row = startRow - while row <= endRow - @autoIndentBufferRow(row) - row++ - return - - autoDecreaseIndentForBufferRow: (bufferRow) -> - indentLevel = @tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) - @setIndentationForBufferRow(bufferRow, indentLevel) if indentLevel? - - toggleLineCommentForBufferRow: (row) -> @toggleLineCommentsForBufferRows(row, row) - - toggleLineCommentsForBufferRows: (start, end) -> @tokenizedBuffer.toggleLineCommentsForBufferRows(start, end) - - rowRangeForParagraphAtBufferRow: (bufferRow) -> - return unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(bufferRow)) - - isCommented = @tokenizedBuffer.isRowCommented(bufferRow) - - startRow = bufferRow - while startRow > 0 - break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(startRow - 1)) - break if @tokenizedBuffer.isRowCommented(startRow - 1) isnt isCommented - startRow-- - - endRow = bufferRow - rowCount = @getLineCount() - while endRow < rowCount - break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(endRow + 1)) - break if @tokenizedBuffer.isRowCommented(endRow + 1) isnt isCommented - endRow++ - - new Range(new Point(startRow, 0), new Point(endRow, @buffer.lineLengthForRow(endRow))) diff --git a/src/text-editor.js b/src/text-editor.js new file mode 100644 index 000000000..a0b9d19a0 --- /dev/null +++ b/src/text-editor.js @@ -0,0 +1,4587 @@ +const _ = require('underscore-plus') +const path = require('path') +const fs = require('fs-plus') +const Grim = require('grim') +const dedent = require('dedent') +const {CompositeDisposable, Disposable, Emitter} = require('event-kit') +const TextBuffer = require('text-buffer') +const {Point, Range} = TextBuffer +const DecorationManager = require('./decoration-manager') +const TokenizedBuffer = require('./tokenized-buffer') +const Cursor = require('./cursor') +const Selection = require('./selection') + +const TextMateScopeSelector = require('first-mate').ScopeSelector +const GutterContainer = require('./gutter-container') +let TextEditorComponent = null +let TextEditorElement = null +const {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require('./text-utils') + +const SERIALIZATION_VERSION = 1 +const NON_WHITESPACE_REGEXP = /\S/ +const ZERO_WIDTH_NBSP = '\ufeff' +let nextId = 0 + +// Essential: This class represents all essential editing state for a single +// {TextBuffer}, including cursor and selection positions, folds, and soft wraps. +// If you're manipulating the state of an editor, use this class. +// +// A single {TextBuffer} can belong to multiple editors. For example, if the +// same file is open in two different panes, Atom creates a separate editor for +// each pane. If the buffer is manipulated the changes are reflected in both +// editors, but each maintains its own cursor position, folded lines, etc. +// +// ## Accessing TextEditor Instances +// +// The easiest way to get hold of `TextEditor` objects is by registering a callback +// with `::observeTextEditors` on the `atom.workspace` global. Your callback will +// then be called with all current editor instances and also when any editor is +// created in the future. +// +// ```coffee +// atom.workspace.observeTextEditors (editor) -> +// editor.insertText('Hello World') +// ``` +// +// ## Buffer vs. Screen Coordinates +// +// Because editors support folds and soft-wrapping, the lines on screen don't +// always match the lines in the buffer. For example, a long line that soft wraps +// twice renders as three lines on screen, but only represents one line in the +// buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds +// to row 11 in the buffer. +// +// Your choice of coordinates systems will depend on what you're trying to +// achieve. For example, if you're writing a command that jumps the cursor up or +// down by 10 lines, you'll want to use screen coordinates because the user +// probably wants to skip lines *on screen*. However, if you're writing a package +// that jumps between method definitions, you'll want to work in buffer +// coordinates. +// +// **When in doubt, just default to buffer coordinates**, then experiment with +// soft wraps and folds to ensure your code interacts with them correctly. +module.exports = +class TextEditor { + static setClipboard (clipboard) { + this.clipboard = clipboard + } + + static setScheduler (scheduler) { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.setScheduler(scheduler) + } + + static didUpdateStyles () { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.didUpdateStyles() + } + + static didUpdateScrollbarStyles () { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.didUpdateScrollbarStyles() + } + + static viewForItem (item) { return item.element || item } + + static deserialize (state, atomEnvironment) { + if (state.version !== SERIALIZATION_VERSION) return null + + try { + const tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) + if (!tokenizedBuffer) return null + + state.tokenizedBuffer = tokenizedBuffer + state.tabLength = state.tokenizedBuffer.getTabLength() + } catch (error) { + if (error.syscall === 'read') { + return // Error reading the file, don't deserialize an editor for it + } else { + throw error + } + } + + state.buffer = state.tokenizedBuffer.buffer + state.assert = atomEnvironment.assert.bind(atomEnvironment) + const editor = new TextEditor(state) + if (state.registered) { + const disposable = atomEnvironment.textEditors.add(editor) + editor.onDidDestroy(() => disposable.dispose()) + } + return editor + } + + constructor (params = {}) { + if (this.constructor.clipboard == null) { + throw new Error('Must call TextEditor.setClipboard at least once before creating TextEditor instances') + } + + this.id = params.id != null ? params.id : nextId++ + this.initialScrollTopRow = params.initialScrollTopRow + this.initialScrollLeftColumn = params.initialScrollLeftColumn + this.decorationManager = params.decorationManager + this.selectionsMarkerLayer = params.selectionsMarkerLayer + this.mini = (params.mini != null) ? params.mini : false + this.placeholderText = params.placeholderText + this.showLineNumbers = params.showLineNumbers + this.largeFileMode = params.largeFileMode + this.assert = params.assert || (condition => condition) + this.showInvisibles = (params.showInvisibles != null) ? params.showInvisibles : true + this.autoHeight = params.autoHeight + this.autoWidth = params.autoWidth + this.scrollPastEnd = (params.scrollPastEnd != null) ? params.scrollPastEnd : false + this.scrollSensitivity = (params.scrollSensitivity != null) ? params.scrollSensitivity : 40 + this.editorWidthInChars = params.editorWidthInChars + this.invisibles = params.invisibles + this.showIndentGuide = params.showIndentGuide + this.softWrapped = params.softWrapped + this.softWrapAtPreferredLineLength = params.softWrapAtPreferredLineLength + this.preferredLineLength = params.preferredLineLength + this.showCursorOnSelection = (params.showCursorOnSelection != null) ? params.showCursorOnSelection : true + this.maxScreenLineLength = params.maxScreenLineLength + this.softTabs = (params.softTabs != null) ? params.softTabs : true + this.autoIndent = (params.autoIndent != null) ? params.autoIndent : true + this.autoIndentOnPaste = (params.autoIndentOnPaste != null) ? params.autoIndentOnPaste : true + this.undoGroupingInterval = (params.undoGroupingInterval != null) ? params.undoGroupingInterval : 300 + this.nonWordCharacters = (params.nonWordCharacters != null) ? params.nonWordCharacters : "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" + this.softWrapped = (params.softWrapped != null) ? params.softWrapped : false + this.softWrapAtPreferredLineLength = (params.softWrapAtPreferredLineLength != null) ? params.softWrapAtPreferredLineLength : false + this.preferredLineLength = (params.preferredLineLength != null) ? params.preferredLineLength : 80 + this.maxScreenLineLength = (params.maxScreenLineLength != null) ? params.maxScreenLineLength : 500 + this.showLineNumbers = (params.showLineNumbers != null) ? params.showLineNumbers : true + const {tabLength = 2} = params + + this.alive = true + this.doBackgroundWork = this.doBackgroundWork.bind(this) + this.serializationVersion = 1 + this.suppressSelectionMerging = false + this.selectionFlashDuration = 500 + this.gutterContainer = null + this.verticalScrollMargin = 2 + this.horizontalScrollMargin = 6 + this.lineHeightInPixels = null + this.defaultCharWidth = null + this.height = null + this.width = null + this.registered = false + this.atomicSoftTabs = true + this.emitter = new Emitter() + this.disposables = new CompositeDisposable() + this.cursors = [] + this.cursorsByMarkerId = new Map() + this.selections = [] + this.hasTerminatedPendingState = false + + this.buffer = params.buffer || new TextBuffer({ + shouldDestroyOnFileDelete () { return atom.config.get('core.closeDeletedFileTabs') } + }) + + this.tokenizedBuffer = params.tokenizedBuffer || new TokenizedBuffer({ + grammar: params.grammar, + tabLength, + buffer: this.buffer, + largeFileMode: this.largeFileMode, + assert: this.assert + }) + + if (params.displayLayer) { + this.displayLayer = params.displayLayer + } else { + const displayLayerParams = { + invisibles: this.getInvisibles(), + softWrapColumn: this.getSoftWrapColumn(), + showIndentGuides: this.doesShowIndentGuide(), + atomicSoftTabs: params.atomicSoftTabs != null ? params.atomicSoftTabs : true, + tabLength, + ratioForCharacter: this.ratioForCharacter.bind(this), + isWrapBoundary, + foldCharacter: ZERO_WIDTH_NBSP, + softWrapHangingIndent: params.softWrapHangingIndentLength != null ? params.softWrapHangingIndentLength : 0 + } + + this.displayLayer = this.buffer.getDisplayLayer(params.displayLayerId) + if (this.displayLayer) { + this.displayLayer.reset(displayLayerParams) + this.selectionsMarkerLayer = this.displayLayer.getMarkerLayer(params.selectionsMarkerLayerId) + } else { + this.displayLayer = this.buffer.addDisplayLayer(displayLayerParams) + } + } + + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork) + this.disposables.add(new Disposable(() => { + if (this.backgroundWorkHandle != null) return cancelIdleCallback(this.backgroundWorkHandle) + })) + + this.defaultMarkerLayer = this.displayLayer.addMarkerLayer() + if (!this.selectionsMarkerLayer) { + this.selectionsMarkerLayer = this.addMarkerLayer({maintainHistory: true, persistent: true}) + } + + this.displayLayer.setTextDecorationLayer(this.tokenizedBuffer) + + this.decorationManager = new DecorationManager(this) + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'cursor'}) + if (!this.isMini()) this.decorateCursorLine() + + this.decorateMarkerLayer(this.displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) + + for (let marker of this.selectionsMarkerLayer.getMarkers()) { + this.addSelection(marker) + } + + this.subscribeToBuffer() + this.subscribeToDisplayLayer() + + if (this.cursors.length === 0 && !params.suppressCursorCreation) { + const initialLine = Math.max(parseInt(params.initialLine) || 0, 0) + const initialColumn = Math.max(parseInt(params.initialColumn) || 0, 0) + this.addCursorAtBufferPosition([initialLine, initialColumn]) + } + + this.gutterContainer = new GutterContainer(this) + this.lineNumberGutter = this.gutterContainer.addGutter({ + name: 'line-number', + priority: 0, + visible: params.lineNumberGutterVisible + }) + } + + get element () { + return this.getElement() + } + + get editorElement () { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.editorElement\` has always been private, but now + it is gone. Reading the \`editorElement\` property still returns a + reference to the editor element but this field will be removed in a + later version of Atom, so we recommend using the \`element\` property instead.\ + `) + + return this.getElement() + } + + get displayBuffer () { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.displayBuffer\` has always been private, but now + it is gone. Reading the \`displayBuffer\` property now returns a reference + to the containing \`TextEditor\`, which now provides *some* of the API of + the defunct \`DisplayBuffer\` class.\ + `) + return this + } + + get languageMode () { + return this.tokenizedBuffer + } + + get rowsPerPage () { + return this.getRowsPerPage() + } + + decorateCursorLine () { + this.cursorLineDecorations = [ + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line', class: 'cursor-line', onlyEmpty: true}), + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line-number', class: 'cursor-line'}), + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true}) + ] + } + + doBackgroundWork (deadline) { + const previousLongestRow = this.getApproximateLongestScreenRow() + if (this.displayLayer.doBackgroundWork(deadline)) { + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork) + } else { + this.backgroundWorkHandle = null + } + + if (this.component && this.getApproximateLongestScreenRow() !== previousLongestRow) { + this.component.scheduleUpdate() + } + } + + update (params) { + const displayLayerParams = {} + + for (let param of Object.keys(params)) { + const value = params[param] + + switch (param) { + case 'autoIndent': + this.autoIndent = value + break + + case 'autoIndentOnPaste': + this.autoIndentOnPaste = value + break + + case 'undoGroupingInterval': + this.undoGroupingInterval = value + break + + case 'nonWordCharacters': + this.nonWordCharacters = value + break + + case 'scrollSensitivity': + this.scrollSensitivity = value + break + + case 'encoding': + this.buffer.setEncoding(value) + break + + case 'softTabs': + if (value !== this.softTabs) { + this.softTabs = value + } + break + + case 'atomicSoftTabs': + if (value !== this.displayLayer.atomicSoftTabs) { + displayLayerParams.atomicSoftTabs = value + } + break + + case 'tabLength': + if (value > 0 && value !== this.tokenizedBuffer.getTabLength()) { + this.tokenizedBuffer.setTabLength(value) + displayLayerParams.tabLength = value + } + break + + case 'softWrapped': + if (value !== this.softWrapped) { + this.softWrapped = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + this.emitter.emit('did-change-soft-wrapped', this.isSoftWrapped()) + } + break + + case 'softWrapHangingIndentLength': + if (value !== this.displayLayer.softWrapHangingIndent) { + displayLayerParams.softWrapHangingIndent = value + } + break + + case 'softWrapAtPreferredLineLength': + if (value !== this.softWrapAtPreferredLineLength) { + this.softWrapAtPreferredLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'preferredLineLength': + if (value !== this.preferredLineLength) { + this.preferredLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'maxScreenLineLength': + if (value !== this.maxScreenLineLength) { + this.maxScreenLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'mini': + if (value !== this.mini) { + this.mini = value + this.emitter.emit('did-change-mini', value) + displayLayerParams.invisibles = this.getInvisibles() + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + displayLayerParams.showIndentGuides = this.doesShowIndentGuide() + if (this.mini) { + for (let decoration of this.cursorLineDecorations) { decoration.destroy() } + this.cursorLineDecorations = null + } else { + this.decorateCursorLine() + } + if (this.component != null) { + this.component.scheduleUpdate() + } + } + break + + case 'placeholderText': + if (value !== this.placeholderText) { + this.placeholderText = value + this.emitter.emit('did-change-placeholder-text', value) + } + break + + case 'lineNumberGutterVisible': + if (value !== this.lineNumberGutterVisible) { + if (value) { + this.lineNumberGutter.show() + } else { + this.lineNumberGutter.hide() + } + this.emitter.emit('did-change-line-number-gutter-visible', this.lineNumberGutter.isVisible()) + } + break + + case 'showIndentGuide': + if (value !== this.showIndentGuide) { + this.showIndentGuide = value + displayLayerParams.showIndentGuides = this.doesShowIndentGuide() + } + break + + case 'showLineNumbers': + if (value !== this.showLineNumbers) { + this.showLineNumbers = value + if (this.component != null) { + this.component.scheduleUpdate() + } + } + break + + case 'showInvisibles': + if (value !== this.showInvisibles) { + this.showInvisibles = value + displayLayerParams.invisibles = this.getInvisibles() + } + break + + case 'invisibles': + if (!_.isEqual(value, this.invisibles)) { + this.invisibles = value + displayLayerParams.invisibles = this.getInvisibles() + } + break + + case 'editorWidthInChars': + if (value > 0 && value !== this.editorWidthInChars) { + this.editorWidthInChars = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'width': + if (value !== this.width) { + this.width = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'scrollPastEnd': + if (value !== this.scrollPastEnd) { + this.scrollPastEnd = value + if (this.component) this.component.scheduleUpdate() + } + break + + case 'autoHeight': + if (value !== this.autoHeight) { + this.autoHeight = value + } + break + + case 'autoWidth': + if (value !== this.autoWidth) { + this.autoWidth = value + } + break + + case 'showCursorOnSelection': + if (value !== this.showCursorOnSelection) { + this.showCursorOnSelection = value + if (this.component) this.component.scheduleUpdate() + } + break + + default: + if (param !== 'ref' && param !== 'key') { + throw new TypeError(`Invalid TextEditor parameter: '${param}'`) + } + } + } + + this.displayLayer.reset(displayLayerParams) + + if (this.component) { + return this.component.getNextUpdatePromise() + } else { + return Promise.resolve() + } + } + + scheduleComponentUpdate () { + if (this.component) this.component.scheduleUpdate() + } + + serialize () { + const tokenizedBufferState = this.tokenizedBuffer.serialize() + + return { + deserializer: 'TextEditor', + version: SERIALIZATION_VERSION, + + // TODO: Remove this forward-compatible fallback once 1.8 reaches stable. + displayBuffer: {tokenizedBuffer: tokenizedBufferState}, + + tokenizedBuffer: tokenizedBufferState, + displayLayerId: this.displayLayer.id, + selectionsMarkerLayerId: this.selectionsMarkerLayer.id, + + initialScrollTopRow: this.getScrollTopRow(), + initialScrollLeftColumn: this.getScrollLeftColumn(), + + atomicSoftTabs: this.displayLayer.atomicSoftTabs, + softWrapHangingIndentLength: this.displayLayer.softWrapHangingIndent, + + id: this.id, + softTabs: this.softTabs, + softWrapped: this.softWrapped, + softWrapAtPreferredLineLength: this.softWrapAtPreferredLineLength, + preferredLineLength: this.preferredLineLength, + mini: this.mini, + editorWidthInChars: this.editorWidthInChars, + width: this.width, + largeFileMode: this.largeFileMode, + maxScreenLineLength: this.maxScreenLineLength, + registered: this.registered, + invisibles: this.invisibles, + showInvisibles: this.showInvisibles, + showIndentGuide: this.showIndentGuide, + autoHeight: this.autoHeight, + autoWidth: this.autoWidth + } + } + + subscribeToBuffer () { + this.buffer.retain() + this.disposables.add(this.buffer.onDidChangePath(() => { + this.emitter.emit('did-change-title', this.getTitle()) + this.emitter.emit('did-change-path', this.getPath()) + })) + this.disposables.add(this.buffer.onDidChangeEncoding(() => { + this.emitter.emit('did-change-encoding', this.getEncoding()) + })) + this.disposables.add(this.buffer.onDidDestroy(() => this.destroy())) + this.disposables.add(this.buffer.onDidChangeModified(() => { + if (!this.hasTerminatedPendingState && this.buffer.isModified()) this.terminatePendingState() + })) + } + + terminatePendingState () { + if (!this.hasTerminatedPendingState) this.emitter.emit('did-terminate-pending-state') + this.hasTerminatedPendingState = true + } + + onDidTerminatePendingState (callback) { + return this.emitter.on('did-terminate-pending-state', callback) + } + + subscribeToDisplayLayer () { + this.disposables.add(this.tokenizedBuffer.onDidChangeGrammar(this.handleGrammarChange.bind(this))) + this.disposables.add(this.displayLayer.onDidChange(changes => { + this.mergeIntersectingSelections() + if (this.component) this.component.didChangeDisplayLayer(changes) + this.emitter.emit('did-change', changes.map(change => new ChangeEvent(change))) + })) + this.disposables.add(this.displayLayer.onDidReset(() => { + this.mergeIntersectingSelections() + if (this.component) this.component.didResetDisplayLayer() + this.emitter.emit('did-change', {}) + })) + this.disposables.add(this.selectionsMarkerLayer.onDidCreateMarker(this.addSelection.bind(this))) + return this.disposables.add(this.selectionsMarkerLayer.onDidUpdate(() => (this.component != null ? this.component.didUpdateSelections() : undefined))) + } + + destroy () { + if (!this.alive) return + this.alive = false + this.disposables.dispose() + this.displayLayer.destroy() + this.tokenizedBuffer.destroy() + for (let selection of this.selections.slice()) { + selection.destroy() + } + this.buffer.release() + this.gutterContainer.destroy() + this.emitter.emit('did-destroy') + this.emitter.clear() + if (this.component) this.component.element.component = null + this.component = null + this.lineNumberGutter.element = null + } + + isAlive () { return this.alive } + + isDestroyed () { return !this.alive } + + /* + Section: Event Subscription + */ + + // Essential: Calls your `callback` when the buffer's title has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeTitle (callback) { + return this.emitter.on('did-change-title', callback) + } + + // Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePath (callback) { + return this.emitter.on('did-change-path', callback) + } + + // Essential: Invoke the given callback synchronously when the content of the + // buffer changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider {::onDidStopChanging} to + // delay expensive operations until after changes stop occurring. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChange (callback) { + return this.emitter.on('did-change', callback) + } + + // Essential: Invoke `callback` when the buffer's contents change. It is + // emit asynchronously 300ms after the last buffer change. This is a good place + // to handle changes to the buffer without compromising typing performance. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChanging (callback) { + return this.getBuffer().onDidStopChanging(callback) + } + + // Essential: Calls your `callback` when a {Cursor} is moved. If there are + // multiple cursors, your callback will be called for each cursor. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferPosition` {Point} + // * `oldScreenPosition` {Point} + // * `newBufferPosition` {Point} + // * `newScreenPosition` {Point} + // * `textChanged` {Boolean} + // * `cursor` {Cursor} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeCursorPosition (callback) { + return this.emitter.on('did-change-cursor-position', callback) + } + + // Essential: Calls your `callback` when a selection's screen range changes. + // + // * `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. + onDidChangeSelectionRange (callback) { + return this.emitter.on('did-change-selection-range', callback) + } + + // Extended: Calls your `callback` when soft wrap was enabled or disabled. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeSoftWrapped (callback) { + return this.emitter.on('did-change-soft-wrapped', callback) + } + + // Extended: Calls your `callback` when the buffer's encoding has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeEncoding (callback) { + return this.emitter.on('did-change-encoding', callback) + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. Immediately calls your callback with + // the current grammar. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGrammar (callback) { + callback(this.getGrammar()) + return this.onDidChangeGrammar(callback) + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeGrammar (callback) { + return this.emitter.on('did-change-grammar', callback) + } + + // Extended: Calls your `callback` when the result of {::isModified} changes. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeModified (callback) { + return this.getBuffer().onDidChangeModified(callback) + } + + // Extended: Calls your `callback` when the buffer's underlying file changes on + // disk at a moment when the result of {::isModified} is true. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidConflict (callback) { + return this.getBuffer().onDidConflict(callback) + } + + // Extended: Calls your `callback` before text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // * `cancel` {Function} Call to prevent the text from being inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillInsertText (callback) { + return this.emitter.on('will-insert-text', callback) + } + + // Extended: Calls your `callback` after text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidInsertText (callback) { + return this.emitter.on('did-insert-text', callback) + } + + // Essential: Invoke the given callback after the buffer is saved to disk. + // + // * `callback` {Function} to be called after the buffer is saved. + // * `event` {Object} with the following keys: + // * `path` The path to which the buffer was saved. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidSave (callback) { + return this.getBuffer().onDidSave(callback) + } + + // Essential: Invoke the given callback when the editor is destroyed. + // + // * `callback` {Function} to be called when the editor is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy (callback) { + return this.emitter.once('did-destroy', callback) + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // Immediately calls your callback for each existing cursor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeCursors (callback) { + this.getCursors().forEach(callback) + return this.onDidAddCursor(callback) + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddCursor (callback) { + return this.emitter.on('did-add-cursor', callback) + } + + // Extended: Calls your `callback` when a {Cursor} is removed from the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveCursor (callback) { + return this.emitter.on('did-remove-cursor', callback) + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // Immediately calls your callback for each existing selection. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeSelections (callback) { + this.getSelections().forEach(callback) + return this.onDidAddSelection(callback) + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddSelection (callback) { + return this.emitter.on('did-add-selection', callback) + } + + // Extended: Calls your `callback` when a {Selection} is removed from the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveSelection (callback) { + return this.emitter.on('did-remove-selection', callback) + } + + // Extended: Calls your `callback` with each {Decoration} added to the editor. + // Calls your `callback` immediately for any existing decorations. + // + // * `callback` {Function} + // * `decoration` {Decoration} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeDecorations (callback) { + return this.decorationManager.observeDecorations(callback) + } + + // Extended: Calls your `callback` when a {Decoration} is added to the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddDecoration (callback) { + return this.decorationManager.onDidAddDecoration(callback) + } + + // Extended: Calls your `callback` when a {Decoration} is removed from the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveDecoration (callback) { + return this.decorationManager.onDidRemoveDecoration(callback) + } + + // Called by DecorationManager when a decoration is added. + didAddDecoration (decoration) { + if (this.component && decoration.isType('block')) { + this.component.addBlockDecoration(decoration) + } + } + + // Extended: Calls your `callback` when the placeholder text is changed. + // + // * `callback` {Function} + // * `placeholderText` {String} new text + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePlaceholderText (callback) { + return this.emitter.on('did-change-placeholder-text', callback) + } + + onDidChangeScrollTop (callback) { + Grim.deprecate('This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.') + return this.getElement().onDidChangeScrollTop(callback) + } + + onDidChangeScrollLeft (callback) { + Grim.deprecate('This is now a view method. Call TextEditorElement::onDidChangeScrollLeft instead.') + return this.getElement().onDidChangeScrollLeft(callback) + } + + onDidRequestAutoscroll (callback) { + return this.emitter.on('did-request-autoscroll', callback) + } + + // TODO Remove once the tabs package no longer uses .on subscriptions + onDidChangeIcon (callback) { + return this.emitter.on('did-change-icon', callback) + } + + onDidUpdateDecorations (callback) { + return this.decorationManager.onDidUpdateDecorations(callback) + } + + // Essential: Retrieves the current {TextBuffer}. + getBuffer () { return this.buffer } + + // Retrieves the current buffer's URI. + getURI () { return this.buffer.getUri() } + + // Create an {TextEditor} with its initial state based on this object + copy () { + const displayLayer = this.displayLayer.copy() + const selectionsMarkerLayer = displayLayer.getMarkerLayer(this.buffer.getMarkerLayer(this.selectionsMarkerLayer.id).copy().id) + const softTabs = this.getSoftTabs() + return new TextEditor({ + buffer: this.buffer, + selectionsMarkerLayer, + softTabs, + suppressCursorCreation: true, + tabLength: this.tokenizedBuffer.getTabLength(), + initialScrollTopRow: this.getScrollTopRow(), + initialScrollLeftColumn: this.getScrollLeftColumn(), + assert: this.assert, + displayLayer, + grammar: this.getGrammar(), + autoWidth: this.autoWidth, + autoHeight: this.autoHeight, + showCursorOnSelection: this.showCursorOnSelection + }) + } + + // Controls visibility based on the given {Boolean}. + setVisible (visible) { this.tokenizedBuffer.setVisible(visible) } + + setMini (mini) { + this.update({mini}) + } + + isMini () { return this.mini } + + onDidChangeMini (callback) { + return this.emitter.on('did-change-mini', callback) + } + + setLineNumberGutterVisible (lineNumberGutterVisible) { this.update({lineNumberGutterVisible}) } + + isLineNumberGutterVisible () { return this.lineNumberGutter.isVisible() } + + onDidChangeLineNumberGutterVisible (callback) { + return this.emitter.on('did-change-line-number-gutter-visible', callback) + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // Immediately calls your callback for each existing gutter. + // + // * `callback` {Function} + // * `gutter` {Gutter} that currently exists/was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGutters (callback) { + return this.gutterContainer.observeGutters(callback) + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // + // * `callback` {Function} + // * `gutter` {Gutter} that was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddGutter (callback) { + return this.gutterContainer.onDidAddGutter(callback) + } + + // Essential: Calls your `callback` when a {Gutter} is removed from the editor. + // + // * `callback` {Function} + // * `name` The name of the {Gutter} that was removed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveGutter (callback) { + return this.gutterContainer.onDidRemoveGutter(callback) + } + + // Set the number of characters that can be displayed horizontally in the + // editor. + // + // * `editorWidthInChars` A {Number} representing the width of the + // {TextEditorElement} in characters. + setEditorWidthInChars (editorWidthInChars) { this.update({editorWidthInChars}) } + + // Returns the editor width in characters. + getEditorWidthInChars () { + if (this.width != null && this.defaultCharWidth > 0) { + return Math.max(0, Math.floor(this.width / this.defaultCharWidth)) + } else { + return this.editorWidthInChars + } + } + + /* + Section: File Details + */ + + // Essential: Get the editor's title for display in other parts of the + // UI such as the tabs. + // + // If the editor's buffer is saved, its title is the file name. If it is + // unsaved, its title is "untitled". + // + // Returns a {String}. + getTitle () { + return this.getFileName() || 'untitled' + } + + // Essential: Get unique title for display in other parts of the UI, such as + // the window title. + // + // If the editor's buffer is unsaved, its title is "untitled" + // If the editor's buffer is saved, its unique title is formatted as one + // of the following, + // * "" when it is the only editing buffer with this file name. + // * " — " when other buffers have this file name. + // + // Returns a {String} + getLongTitle () { + if (this.getPath()) { + const fileName = this.getFileName() + + let myPathSegments + const openEditorPathSegmentsWithSameFilename = [] + for (const textEditor of atom.workspace.getTextEditors()) { + if (textEditor.getFileName() === fileName) { + const pathSegments = fs.tildify(textEditor.getDirectoryPath()).split(path.sep) + openEditorPathSegmentsWithSameFilename.push(pathSegments) + if (textEditor === this) myPathSegments = pathSegments + } + } + + if (!myPathSegments || openEditorPathSegmentsWithSameFilename.length === 1) return fileName + + let commonPathSegmentCount + for (let i = 0, {length} = myPathSegments; i < length; i++) { + const myPathSegment = myPathSegments[i] + if (openEditorPathSegmentsWithSameFilename.some(segments => (segments.length === i + 1) || (segments[i] !== myPathSegment))) { + commonPathSegmentCount = i + break + } + } + + return `${fileName} \u2014 ${path.join(...myPathSegments.slice(commonPathSegmentCount))}` + } else { + return 'untitled' + } + } + + // Essential: Returns the {String} path of this editor's text buffer. + getPath () { + return this.buffer.getPath() + } + + getFileName () { + const fullPath = this.getPath() + if (fullPath) return path.basename(fullPath) + } + + getDirectoryPath () { + const fullPath = this.getPath() + if (fullPath) return path.dirname(fullPath) + } + + // Extended: Returns the {String} character set encoding of this editor's text + // buffer. + getEncoding () { return this.buffer.getEncoding() } + + // Extended: Set the character set encoding to use in this editor's text + // buffer. + // + // * `encoding` The {String} character set encoding name such as 'utf8' + setEncoding (encoding) { this.buffer.setEncoding(encoding) } + + // Essential: Returns {Boolean} `true` if this editor has been modified. + isModified () { return this.buffer.isModified() } + + // Essential: Returns {Boolean} `true` if this editor has no content. + isEmpty () { return this.buffer.isEmpty() } + + /* + Section: File Operations + */ + + // Essential: Saves the editor's text buffer. + // + // See {TextBuffer::save} for more details. + save () { return this.buffer.save() } + + // Essential: Saves the editor's text buffer as the given path. + // + // See {TextBuffer::saveAs} for more details. + // + // * `filePath` A {String} path. + saveAs (filePath) { return this.buffer.saveAs(filePath) } + + // Determine whether the user should be prompted to save before closing + // this editor. + shouldPromptToSave ({windowCloseRequested, projectHasPaths} = {}) { + if (windowCloseRequested && projectHasPaths && atom.stateStore.isConnected()) { + return this.buffer.isInConflict() + } else { + return this.isModified() && !this.buffer.hasMultipleEditors() + } + } + + // Returns an {Object} to configure dialog shown when this editor is saved + // via {Pane::saveItemAs}. + getSaveDialogOptions () { return {} } + + /* + Section: Reading Text + */ + + // Essential: Returns a {String} representing the entire contents of the editor. + getText () { return this.buffer.getText() } + + // Essential: Get the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // + // Returns a {String}. + getTextInBufferRange (range) { + return this.buffer.getTextInRange(range) + } + + // Essential: Returns a {Number} representing the number of lines in the buffer. + getLineCount () { return this.buffer.getLineCount() } + + // Essential: Returns a {Number} representing the number of screen lines in the + // editor. This accounts for folds. + getScreenLineCount () { return this.displayLayer.getScreenLineCount() } + + getApproximateScreenLineCount () { return this.displayLayer.getApproximateScreenLineCount() } + + // Essential: Returns a {Number} representing the last zero-indexed buffer row + // number of the editor. + getLastBufferRow () { return this.buffer.getLastRow() } + + // Essential: Returns a {Number} representing the last zero-indexed screen row + // number of the editor. + getLastScreenRow () { return this.getScreenLineCount() - 1 } + + // Essential: Returns a {String} representing the contents of the line at the + // given buffer row. + // + // * `bufferRow` A {Number} representing a zero-indexed buffer row. + lineTextForBufferRow (bufferRow) { return this.buffer.lineForRow(bufferRow) } + + // Essential: Returns a {String} representing the contents of the line at the + // given screen row. + // + // * `screenRow` A {Number} representing a zero-indexed screen row. + lineTextForScreenRow (screenRow) { + const screenLine = this.screenLineForScreenRow(screenRow) + if (screenLine) return screenLine.lineText + } + + logScreenLines (start = 0, end = this.getLastScreenRow()) { + for (let row = start; row <= end; row++) { + const line = this.lineTextForScreenRow(row) + console.log(row, this.bufferRowForScreenRow(row), line, line.length) + } + } + + tokensForScreenRow (screenRow) { + const tokens = [] + let lineTextIndex = 0 + const currentTokenScopes = [] + const {lineText, tags} = this.screenLineForScreenRow(screenRow) + for (const tag of tags) { + if (this.displayLayer.isOpenTag(tag)) { + currentTokenScopes.push(this.displayLayer.classNameForTag(tag)) + } else if (this.displayLayer.isCloseTag(tag)) { + currentTokenScopes.pop() + } else { + tokens.push({ + text: lineText.substr(lineTextIndex, tag), + scopes: currentTokenScopes.slice() + }) + lineTextIndex += tag + } + } + return tokens + } + + screenLineForScreenRow (screenRow) { + return this.displayLayer.getScreenLine(screenRow) + } + + bufferRowForScreenRow (screenRow) { + return this.displayLayer.translateScreenPosition(Point(screenRow, 0)).row + } + + bufferRowsForScreenRows (startScreenRow, endScreenRow) { + return this.displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow + 1) + } + + screenRowForBufferRow (row) { + return this.displayLayer.translateBufferPosition(Point(row, 0)).row + } + + getRightmostScreenPosition () { return this.displayLayer.getRightmostScreenPosition() } + + getApproximateRightmostScreenPosition () { return this.displayLayer.getApproximateRightmostScreenPosition() } + + getMaxScreenLineLength () { return this.getRightmostScreenPosition().column } + + getLongestScreenRow () { return this.getRightmostScreenPosition().row } + + getApproximateLongestScreenRow () { return this.getApproximateRightmostScreenPosition().row } + + lineLengthForScreenRow (screenRow) { return this.displayLayer.lineLengthForScreenRow(screenRow) } + + // Returns the range for the given buffer row. + // + // * `row` A row {Number}. + // * `options` (optional) An options hash with an `includeNewline` key. + // + // Returns a {Range}. + bufferRangeForBufferRow (row, options) { + return this.buffer.rangeForRow(row, options && options.includeNewline) + } + + // Get the text in the given {Range}. + // + // Returns a {String}. + getTextInRange (range) { return this.buffer.getTextInRange(range) } + + // {Delegates to: TextBuffer.isRowBlank} + isBufferRowBlank (bufferRow) { return this.buffer.isRowBlank(bufferRow) } + + // {Delegates to: TextBuffer.nextNonBlankRow} + nextNonBlankBufferRow (bufferRow) { return this.buffer.nextNonBlankRow(bufferRow) } + + // {Delegates to: TextBuffer.getEndPosition} + getEofBufferPosition () { return this.buffer.getEndPosition() } + + // Essential: Get the {Range} of the paragraph surrounding the most recently added + // cursor. + // + // Returns a {Range}. + getCurrentParagraphBufferRange () { + return this.getLastCursor().getCurrentParagraphBufferRange() + } + + /* + Section: Mutating Text + */ + + // Essential: Replaces the entire contents of the buffer with the given {String}. + // + // * `text` A {String} to replace with + setText (text) { return this.buffer.setText(text) } + + // Essential: Set the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // * `text` A {String} + // * `options` (optional) {Object} + // * `normalizeLineEndings` (optional) {Boolean} (default: true) + // * `undo` (optional) {String} 'skip' will skip the undo system + // + // Returns the {Range} of the newly-inserted text. + setTextInBufferRange (range, text, options) { + return this.getBuffer().setTextInRange(range, text, options) + } + + // Essential: For each selection, replace the selected text with the given text. + // + // * `text` A {String} representing the text to insert. + // * `options` (optional) See {Selection::insertText}. + // + // Returns a {Range} when the text has been inserted + // Returns a {Boolean} false when the text has not been inserted + insertText (text, options = {}) { + if (!this.emitWillInsertTextEvent(text)) return false + + const groupingInterval = options.groupUndo ? this.undoGroupingInterval : 0 + if (options.autoIndentNewline == null) options.autoIndentNewline = this.shouldAutoIndent() + if (options.autoDecreaseIndent == null) options.autoDecreaseIndent = this.shouldAutoIndent() + return this.mutateSelectedText(selection => { + const range = selection.insertText(text, options) + const didInsertEvent = {text, range} + this.emitter.emit('did-insert-text', didInsertEvent) + return range + }, groupingInterval) + } + + // Essential: For each selection, replace the selected text with a newline. + insertNewline (options) { + return this.insertText('\n', options) + } + + // Essential: For each selection, if the selection is empty, delete the character + // following the cursor. Otherwise delete the selected text. + delete () { + return this.mutateSelectedText(selection => selection.delete()) + } + + // Essential: For each selection, if the selection is empty, delete the character + // preceding the cursor. Otherwise delete the selected text. + backspace () { + return this.mutateSelectedText(selection => selection.backspace()) + } + + // Extended: Mutate the text of all the selections in a single transaction. + // + // All the changes made inside the given {Function} can be reverted with a + // single call to {::undo}. + // + // * `fn` A {Function} that will be called once for each {Selection}. The first + // argument will be a {Selection} and the second argument will be the + // {Number} index of that selection. + mutateSelectedText (fn, groupingInterval = 0) { + return this.mergeIntersectingSelections(() => { + return this.transact(groupingInterval, () => { + return this.getSelectionsOrderedByBufferPosition().map((selection, index) => fn(selection, index)) + }) + }) + } + + // Move lines intersecting the most recent selection or multiple selections + // up by one row in screen coordinates. + moveLineUp () { + const selections = this.getSelectedBufferRanges().sort((a, b) => a.compare(b)) + + if (selections[0].start.row === 0) return + if (selections[selections.length - 1].start.row === this.getLastBufferRow() && this.buffer.getLastLine() === '') return + + this.transact(() => { + const newSelectionRanges = [] + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift() + const selectionsToMove = [selection] + + while (selection.end.row === (selections[0] != null ? selections[0].start.row : undefined)) { + selectionsToMove.push(selections[0]) + selection.end.row = selections[0].end.row + selections.shift() + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row + let endRow = selection.end.row + if (selection.end.row > selection.start.row && selection.end.column === 0) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow-- + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow) + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1) + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) + + // If selected line range is preceded by a fold, one line above on screen + // could be multiple lines in the buffer. + const precedingRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow - 1) + const insertDelta = linesRange.start.row - precedingRow + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([-insertDelta, 0])) + + // Delete lines spanned by selection and insert them on the preceding buffer row + let lines = this.buffer.getTextInRange(linesRange) + if (lines[lines.length - 1] !== '\n') { lines += this.buffer.lineEndingForRow(linesRange.end.row - 2) } + this.buffer.delete(linesRange) + this.buffer.insert([precedingRow, 0], lines) + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold) + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([-insertDelta, 0])) + } + } + + this.setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) + if (this.shouldAutoIndent()) this.autoIndentSelectedRows() + this.scrollToBufferPosition([newSelectionRanges[0].start.row, 0]) + }) + } + + // Move lines intersecting the most recent selection or multiple selections + // down by one row in screen coordinates. + moveLineDown () { + const selections = this.getSelectedBufferRanges() + selections.sort((a, b) => b.compare(a)) + + this.transact(() => { + this.consolidateSelections() + const newSelectionRanges = [] + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift() + const selectionsToMove = [selection] + + // if the current selection start row matches the next selections' end row - make them one selection + while (selection.start.row === (selections[0] != null ? selections[0].end.row : undefined)) { + selectionsToMove.push(selections[0]) + selection.start.row = selections[0].start.row + selections.shift() + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row + let endRow = selection.end.row + if (selection.end.row > selection.start.row && selection.end.column === 0) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow-- + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow) + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1) + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) + + // If selected line range is followed by a fold, one line below on screen + // could be multiple lines in the buffer. But at the same time, if the + // next buffer row is wrapped, one line in the buffer can represent many + // screen rows. + const followingRow = Math.min(this.buffer.getLineCount(), this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1)) + const insertDelta = followingRow - linesRange.end.row + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([insertDelta, 0])) + + // Delete lines spanned by selection and insert them on the following correct buffer row + let lines = this.buffer.getTextInRange(linesRange) + if (followingRow - 1 === this.buffer.getLastRow()) { + lines = `\n${lines}` + } + + this.buffer.insert([followingRow, 0], lines) + this.buffer.delete(linesRange) + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold) + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([insertDelta, 0])) + } + } + + this.setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) + if (this.shouldAutoIndent()) this.autoIndentSelectedRows() + this.scrollToBufferPosition([newSelectionRanges[0].start.row - 1, 0]) + }) + } + + // Move any active selections one column to the left. + moveSelectionLeft () { + const selections = this.getSelectedBufferRanges() + const noSelectionAtStartOfLine = selections.every(selection => selection.start.column !== 0) + + const translationDelta = [0, -1] + const translatedRanges = [] + + if (noSelectionAtStartOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToLeftOfSelection = new Range(selection.start.translate(translationDelta), selection.start) + const charTextToLeftOfSelection = this.buffer.getTextInRange(charToLeftOfSelection) + + this.buffer.insert(selection.end, charTextToLeftOfSelection) + this.buffer.delete(charToLeftOfSelection) + translatedRanges.push(selection.translate(translationDelta)) + } + + this.setSelectedBufferRanges(translatedRanges) + }) + } + } + + // Move any active selections one column to the right. + moveSelectionRight () { + const selections = this.getSelectedBufferRanges() + const noSelectionAtEndOfLine = selections.every(selection => { + return selection.end.column !== this.buffer.lineLengthForRow(selection.end.row) + }) + + const translationDelta = [0, 1] + const translatedRanges = [] + + if (noSelectionAtEndOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToRightOfSelection = new Range(selection.end, selection.end.translate(translationDelta)) + const charTextToRightOfSelection = this.buffer.getTextInRange(charToRightOfSelection) + + this.buffer.delete(charToRightOfSelection) + this.buffer.insert(selection.start, charTextToRightOfSelection) + translatedRanges.push(selection.translate(translationDelta)) + } + + this.setSelectedBufferRanges(translatedRanges) + }) + } + } + + duplicateLines () { + this.transact(() => { + const selections = this.getSelectionsOrderedByBufferPosition() + const previousSelectionRanges = [] + + let i = selections.length - 1 + while (i >= 0) { + const j = i + previousSelectionRanges[i] = selections[i].getBufferRange() + if (selections[i].isEmpty()) { + const {start} = selections[i].getScreenRange() + selections[i].setScreenRange([[start.row, 0], [start.row + 1, 0]], {preserveFolds: true}) + } + let [startRow, endRow] = selections[i].getBufferRowRange() + endRow++ + while (i > 0) { + const [previousSelectionStartRow, previousSelectionEndRow] = selections[i - 1].getBufferRowRange() + if (previousSelectionEndRow === startRow) { + startRow = previousSelectionStartRow + previousSelectionRanges[i - 1] = selections[i - 1].getBufferRange() + i-- + } else { + break + } + } + + const intersectingFolds = this.displayLayer.foldsIntersectingBufferRange([[startRow, 0], [endRow, 0]]) + let textToDuplicate = this.getTextInBufferRange([[startRow, 0], [endRow, 0]]) + if (endRow > this.getLastBufferRow()) textToDuplicate = `\n${textToDuplicate}` + this.buffer.insert([endRow, 0], textToDuplicate) + + const insertedRowCount = endRow - startRow + + for (let k = i; k <= j; k++) { + selections[k].setBufferRange(previousSelectionRanges[k].translate([insertedRowCount, 0])) + } + + for (const fold of intersectingFolds) { + const foldRange = this.displayLayer.bufferRangeForFold(fold) + this.displayLayer.foldBufferRange(foldRange.translate([insertedRowCount, 0])) + } + + i-- + } + }) + } + + replaceSelectedText (options, fn) { + this.mutateSelectedText((selection) => { + selection.getBufferRange() + if (options && options.selectWordIfEmpty && selection.isEmpty()) { + selection.selectWord() + } + const text = selection.getText() + selection.deleteSelectedText() + const range = selection.insertText(fn(text)) + selection.setBufferRange(range) + }) + } + + // Split multi-line selections into one selection per line. + // + // Operates on all selections. This method breaks apart all multi-line + // selections to create multiple single-line selections that cumulatively cover + // the same original area. + splitSelectionsIntoLines () { + this.mergeIntersectingSelections(() => { + for (const selection of this.getSelections()) { + const range = selection.getBufferRange() + if (range.isSingleLine()) continue + + const {start, end} = range + this.addSelectionForBufferRange([start, [start.row, Infinity]]) + let {row} = start + while (++row < end.row) { + this.addSelectionForBufferRange([[row, 0], [row, Infinity]]) + } + if (end.column !== 0) this.addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) + selection.destroy() + } + }) + } + + // Extended: For each selection, transpose the selected text. + // + // If the selection is empty, the characters preceding and following the cursor + // are swapped. Otherwise, the selected characters are reversed. + transpose () { + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectRight() + const text = selection.getText() + selection.delete() + selection.cursor.moveLeft() + selection.insertText(text) + } else { + selection.insertText(selection.getText().split('').reverse().join('')) + } + }) + } + + // Extended: Convert the selected text to upper case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + upperCase () { + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase()) + } + + // Extended: Convert the selected text to lower case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + lowerCase () { + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase()) + } + + // Extended: Toggle line comments for rows intersecting selections. + // + // If the current grammar doesn't support comments, does nothing. + toggleLineCommentsInSelection () { + this.mutateSelectedText(selection => selection.toggleLineComments()) + } + + // Convert multiple lines to a single line. + // + // Operates on all selections. If the selection is empty, joins the current + // line with the next line. Otherwise it joins all lines that intersect the + // selection. + // + // Joining a line means that multiple lines are converted to a single line with + // the contents of each of the original non-empty lines separated by a space. + joinLines () { + this.mutateSelectedText(selection => selection.joinLines()) + } + + // Extended: For each cursor, insert a newline at beginning the following line. + insertNewlineBelow () { + this.transact(() => { + this.moveToEndOfLine() + this.insertNewline() + }) + } + + // Extended: For each cursor, insert a newline at the end of the preceding line. + insertNewlineAbove () { + this.transact(() => { + const bufferRow = this.getCursorBufferPosition().row + const indentLevel = this.indentationForBufferRow(bufferRow) + const onFirstLine = bufferRow === 0 + + this.moveToBeginningOfLine() + this.moveLeft() + this.insertNewline() + + if (this.shouldAutoIndent() && (this.indentationForBufferRow(bufferRow) < indentLevel)) { + this.setIndentationForBufferRow(bufferRow, indentLevel) + } + + if (onFirstLine) { + this.moveUp() + this.moveToEndOfLine() + } + }) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word that precede the cursor. Otherwise delete the + // selected text. + deleteToBeginningOfWord () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfWord()) + } + + // Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the + // previous word boundary. + deleteToPreviousWordBoundary () { + this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary()) + } + + // Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the + // next word boundary. + deleteToNextWordBoundary () { + this.mutateSelectedText(selection => selection.deleteToNextWordBoundary()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + deleteToBeginningOfSubword () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + deleteToEndOfSubword () { + this.mutateSelectedText(selection => selection.deleteToEndOfSubword()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing line that precede the cursor. Otherwise delete the + // selected text. + deleteToBeginningOfLine () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfLine()) + } + + // Extended: For each selection, if the selection is not empty, deletes the + // selection; otherwise, deletes all characters of the containing line + // following the cursor. If the cursor is already at the end of the line, + // deletes the following newline. + deleteToEndOfLine () { + this.mutateSelectedText(selection => selection.deleteToEndOfLine()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word following the cursor. Otherwise delete the selected + // text. + deleteToEndOfWord () { + this.mutateSelectedText(selection => selection.deleteToEndOfWord()) + } + + // Extended: Delete all lines intersecting selections. + deleteLine () { + this.mergeSelectionsOnSameRows() + this.mutateSelectedText(selection => selection.deleteLine()) + } + + /* + Section: History + */ + + // Essential: Undo the last change. + undo () { + this.avoidMergingSelections(() => this.buffer.undo()) + this.getLastSelection().autoscroll() + } + + // Essential: Redo the last change. + redo () { + this.avoidMergingSelections(() => this.buffer.redo()) + this.getLastSelection().autoscroll() + } + + // Extended: Batch multiple operations as a single undo/redo step. + // + // Any group of operations that are logically grouped from the perspective of + // undoing and redoing should be performed in a transaction. If you want to + // abort the transaction, call {::abortTransaction} to terminate the function's + // execution and revert any changes performed up to the abortion. + // + // * `groupingInterval` (optional) The {Number} of milliseconds for which this + // transaction should be considered 'groupable' after it begins. If a transaction + // with a positive `groupingInterval` is committed while the previous transaction is + // still 'groupable', the two transactions are merged with respect to undo and redo. + // * `fn` A {Function} to call inside the transaction. + transact (groupingInterval, fn) { + return this.buffer.transact(groupingInterval, fn) + } + + // Extended: Abort an open transaction, undoing any operations performed so far + // within the transaction. + abortTransaction () { return this.buffer.abortTransaction() } + + // Extended: Create a pointer to the current state of the buffer for use + // with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. + // + // Returns a checkpoint value. + createCheckpoint () { return this.buffer.createCheckpoint() } + + // Extended: Revert the buffer to the state it was in when the given + // checkpoint was created. + // + // The redo stack will be empty following this operation, so changes since the + // checkpoint will be lost. If the given checkpoint is no longer present in the + // undo history, no changes will be made to the buffer and this method will + // return `false`. + // + // * `checkpoint` The checkpoint to revert to. + // + // Returns a {Boolean} indicating whether the operation succeeded. + revertToCheckpoint (checkpoint) { return this.buffer.revertToCheckpoint(checkpoint) } + + // Extended: Group all changes since the given checkpoint into a single + // transaction for purposes of undo/redo. + // + // If the given checkpoint is no longer present in the undo history, no + // grouping will be performed and this method will return `false`. + // + // * `checkpoint` The checkpoint from which to group changes. + // + // Returns a {Boolean} indicating whether the operation succeeded. + groupChangesSinceCheckpoint (checkpoint) { return this.buffer.groupChangesSinceCheckpoint(checkpoint) } + + /* + Section: TextEditor Coordinates + */ + + // Essential: Convert a position in buffer-coordinates to screen-coordinates. + // + // The position is clipped via {::clipBufferPosition} prior to the conversion. + // The position is also clipped via {::clipScreenPosition} following the + // conversion, which only makes a difference when `options` are supplied. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + screenPositionForBufferPosition (bufferPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.translateBufferPosition(bufferPosition, options) + } + + // Essential: Convert a position in screen-coordinates to buffer-coordinates. + // + // The position is clipped via {::clipScreenPosition} prior to the conversion. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + bufferPositionForScreenPosition (screenPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.translateScreenPosition(screenPosition, options) + } + + // Essential: Convert a range in buffer-coordinates to screen-coordinates. + // + // * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. + // + // Returns a {Range}. + screenRangeForBufferRange (bufferRange, options) { + bufferRange = Range.fromObject(bufferRange) + const start = this.screenPositionForBufferPosition(bufferRange.start, options) + const end = this.screenPositionForBufferPosition(bufferRange.end, options) + return new Range(start, end) + } + + // Essential: Convert a range in screen-coordinates to buffer-coordinates. + // + // * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. + // + // Returns a {Range}. + bufferRangeForScreenRange (screenRange) { + screenRange = Range.fromObject(screenRange) + const start = this.bufferPositionForScreenPosition(screenRange.start) + const end = this.bufferPositionForScreenPosition(screenRange.end) + return new Range(start, end) + } + + // Extended: Clip the given {Point} to a valid position in the buffer. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the buffer, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```coffee + // editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` + // + // # When the line at buffer row 2 is 10 characters long + // editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` + // ``` + // + // * `bufferPosition` The {Point} representing the position to clip. + // + // Returns a {Point}. + clipBufferPosition (bufferPosition) { return this.buffer.clipPosition(bufferPosition) } + + // Extended: Clip the start and end of the given range to valid positions in the + // buffer. See {::clipBufferPosition} for more information. + // + // * `range` The {Range} to clip. + // + // Returns a {Range}. + clipBufferRange (range) { return this.buffer.clipRange(range) } + + // Extended: Clip the given {Point} to a valid position on screen. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the screen, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```coffee + // editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` + // + // # When the line at screen row 2 is 10 characters long + // editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` + // ``` + // + // * `screenPosition` The {Point} representing the position to clip. + // * `options` (optional) {Object} + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {Point}. + clipScreenPosition (screenPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.clipScreenPosition(screenPosition, options) + } + + // Extended: Clip the start and end of the given range to valid positions on screen. + // See {::clipScreenPosition} for more information. + // + // * `range` The {Range} to clip. + // * `options` (optional) See {::clipScreenPosition} `options`. + // + // Returns a {Range}. + clipScreenRange (screenRange, options) { + screenRange = Range.fromObject(screenRange) + const start = this.displayLayer.clipScreenPosition(screenRange.start, options) + const end = this.displayLayer.clipScreenPosition(screenRange.end, options) + return Range(start, end) + } + + /* + Section: Decorations + */ + + // Essential: Add a decoration that tracks a {DisplayMarker}. When the + // marker moves, is invalidated, or is destroyed, the decoration will be + // updated to reflect the marker's state. + // + // The following are the supported decorations types: + // + // * __line__: Adds your CSS `class` to the line nodes within the range + // marked by the marker + // * __line-number__: Adds your CSS `class` to the line number nodes within the + // range marked by the marker + // * __highlight__: Adds a new highlight div to the editor surrounding the + // range marked by the marker. When the user selects text, the selection is + // visualized with a highlight decoration internally. The structure of this + // highlight will be + // ```html + //
+ // + //
+ //
+ // ``` + // * __overlay__: Positions the view associated with the given item at the head + // or tail of the given `DisplayMarker`. + // * __gutter__: A decoration that tracks a {DisplayMarker} in a {Gutter}. Gutter + // decorations are created by calling {Gutter::decorateMarker} on the + // desired `Gutter` instance. + // * __block__: Positions the view associated with the given item before or + // after the row of the given `TextEditorMarker`. + // + // ## Arguments + // + // * `marker` A {DisplayMarker} you want this decoration to follow. + // * `decorationParams` An {Object} representing the decoration e.g. + // `{type: 'line-number', class: 'linter-error'}` + // * `type` There are several supported decoration types. The behavior of the + // types are as follows: + // * `line` Adds the given `class` to the lines overlapping the rows + // spanned by the `DisplayMarker`. + // * `line-number` Adds the given `class` to the line numbers overlapping + // the rows spanned by the `DisplayMarker`. + // * `text` Injects spans into all text overlapping the marked range, + // then adds the given `class` or `style` properties to these spans. + // Use this to manipulate the foreground color or styling of text in + // a given range. + // * `highlight` Creates an absolutely-positioned `.highlight` div + // containing nested divs to cover the marked region. For example, this + // is used to implement selections. + // * `overlay` Positions the view associated with the given item at the + // head or tail of the given `DisplayMarker`, depending on the `position` + // property. + // * `gutter` Tracks a {DisplayMarker} in a {Gutter}. Created by calling + // {Gutter::decorateMarker} on the desired `Gutter` instance. + // * `block` Positions the view associated with the given item before or + // after the row of the given `TextEditorMarker`, depending on the `position` + // property. + // * `cursor` Renders a cursor at the head of the given marker. If multiple + // decorations are created for the same marker, their class strings and + // style objects are combined into a single cursor. You can use this + // decoration type to style existing cursors by passing in their markers + // or render artificial cursors that don't actually exist in the model + // by passing a marker that isn't actually associated with a cursor. + // * `class` This CSS class will be applied to the decorated line number, + // line, text spans, highlight regions, cursors, or overlay. + // * `style` An {Object} containing CSS style properties to apply to the + // relevant DOM node. Currently this only works with a `type` of `cursor` + // or `text`. + // * `item` (optional) An {HTMLElement} or a model {Object} with a + // corresponding view registered. Only applicable to the `gutter`, + // `overlay` and `block` decoration types. + // * `onlyHead` (optional) If `true`, the decoration will only be applied to + // the head of the `DisplayMarker`. Only applicable to the `line` and + // `line-number` decoration types. + // * `onlyEmpty` (optional) If `true`, the decoration will only be applied if + // the associated `DisplayMarker` is empty. Only applicable to the `gutter`, + // `line`, and `line-number` decoration types. + // * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied + // if the associated `DisplayMarker` is non-empty. Only applicable to the + // `gutter`, `line`, and `line-number` decoration types. + // * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied + // to the last row of a non-empty range, even if it ends at column 0. + // Defaults to `true`. Only applicable to the `gutter`, `line`, and + // `line-number` decoration types. + // * `position` (optional) Only applicable to decorations of type `overlay` and `block`. + // Controls where the view is positioned relative to the `TextEditorMarker`. + // Values can be `'head'` (the default) or `'tail'` for overlay decorations, and + // `'before'` (the default) or `'after'` for block decorations. + // * `avoidOverflow` (optional) Only applicable to decorations of type + // `overlay`. Determines whether the decoration adjusts its horizontal or + // vertical position to remain fully visible when it would otherwise + // overflow the editor. Defaults to `true`. + // + // Returns a {Decoration} object + decorateMarker (marker, decorationParams) { + return this.decorationManager.decorateMarker(marker, decorationParams) + } + + // Essential: Add a decoration to every marker in the given marker layer. Can + // be used to decorate a large number of markers without having to create and + // manage many individual decorations. + // + // * `markerLayer` A {DisplayMarkerLayer} or {MarkerLayer} to decorate. + // * `decorationParams` The same parameters that are passed to + // {TextEditor::decorateMarker}, except the `type` cannot be `overlay` or `gutter`. + // + // Returns a {LayerDecoration}. + decorateMarkerLayer (markerLayer, decorationParams) { + return this.decorationManager.decorateMarkerLayer(markerLayer, decorationParams) + } + + // Deprecated: Get all the decorations within a screen row range on the default + // layer. + // + // * `startScreenRow` the {Number} beginning screen row + // * `endScreenRow` the {Number} end screen row (inclusive) + // + // Returns an {Object} of decorations in the form + // `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` + // where the keys are {DisplayMarker} IDs, and the values are an array of decoration + // params objects attached to the marker. + // Returns an empty object when no decorations are found + decorationsForScreenRowRange (startScreenRow, endScreenRow) { + return this.decorationManager.decorationsForScreenRowRange(startScreenRow, endScreenRow) + } + + decorationsStateForScreenRowRange (startScreenRow, endScreenRow) { + return this.decorationManager.decorationsStateForScreenRowRange(startScreenRow, endScreenRow) + } + + // Extended: Get all decorations. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getDecorations (propertyFilter) { + return this.decorationManager.getDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'line'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineDecorations (propertyFilter) { + return this.decorationManager.getLineDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'line-number'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineNumberDecorations (propertyFilter) { + return this.decorationManager.getLineNumberDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'highlight'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getHighlightDecorations (propertyFilter) { + return this.decorationManager.getHighlightDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'overlay'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getOverlayDecorations (propertyFilter) { + return this.decorationManager.getOverlayDecorations(propertyFilter) + } + + /* + Section: Markers + */ + + // Essential: Create a marker on the default marker layer with the given range + // in buffer coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferRange (bufferRange, options) { + return this.defaultMarkerLayer.markBufferRange(bufferRange, options) + } + + // Essential: Create a marker on the default marker layer with the given range + // in screen coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markScreenRange (screenRange, options) { + return this.defaultMarkerLayer.markScreenRange(screenRange, options) + } + + // Essential: Create a marker on the default marker layer with the given buffer + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `bufferPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferPosition (bufferPosition, options) { + return this.defaultMarkerLayer.markBufferPosition(bufferPosition, options) + } + + // Essential: Create a marker on the default marker layer with the given screen + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `screenPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {DisplayMarker}. + markScreenPosition (screenPosition, options) { + return this.defaultMarkerLayer.markScreenPosition(screenPosition, options) + } + + // Essential: Find all {DisplayMarker}s on the default marker layer that + // match the given properties. + // + // This method finds markers based on the given properties. Markers can be + // associated with custom properties that will be compared with basic equality. + // In addition, there are several special properties that will be compared + // with the range of the markers rather than their properties. + // + // * `properties` An {Object} containing properties that each returned marker + // must satisfy. Markers can be associated with custom properties, which are + // compared with basic equality. In addition, several reserved properties + // can be used to filter markers based on their current range: + // * `startBufferRow` Only include markers starting at this row in buffer + // coordinates. + // * `endBufferRow` Only include markers ending at this row in buffer + // coordinates. + // * `containsBufferRange` Only include markers containing this {Range} or + // in range-compatible {Array} in buffer coordinates. + // * `containsBufferPosition` Only include markers containing this {Point} + // or {Array} of `[row, column]` in buffer coordinates. + // + // Returns an {Array} of {DisplayMarker}s + findMarkers (params) { + return this.defaultMarkerLayer.findMarkers(params) + } + + // Extended: Get the {DisplayMarker} on the default layer for the given + // marker id. + // + // * `id` {Number} id of the marker + getMarker (id) { + return this.defaultMarkerLayer.getMarker(id) + } + + // Extended: Get all {DisplayMarker}s on the default marker layer. Consider + // using {::findMarkers} + getMarkers () { + return this.defaultMarkerLayer.getMarkers() + } + + // Extended: Get the number of markers in the default marker layer. + // + // Returns a {Number}. + getMarkerCount () { + return this.defaultMarkerLayer.getMarkerCount() + } + + destroyMarker (id) { + const marker = this.getMarker(id) + if (marker) marker.destroy() + } + + // Essential: Create a marker layer to group related markers. + // + // * `options` An {Object} containing the following keys: + // * `maintainHistory` A {Boolean} indicating whether marker state should be + // restored on undo/redo. Defaults to `false`. + // * `persistent` A {Boolean} indicating whether or not this marker layer + // should be serialized and deserialized along with the rest of the + // buffer. Defaults to `false`. If `true`, the marker layer's id will be + // maintained across the serialization boundary, allowing you to retrieve + // it via {::getMarkerLayer}. + // + // Returns a {DisplayMarkerLayer}. + addMarkerLayer (options) { + return this.displayLayer.addMarkerLayer(options) + } + + // Essential: Get a {DisplayMarkerLayer} by id. + // + // * `id` The id of the marker layer to retrieve. + // + // Returns a {DisplayMarkerLayer} or `undefined` if no layer exists with the + // given id. + getMarkerLayer (id) { + return this.displayLayer.getMarkerLayer(id) + } + + // Essential: Get the default {DisplayMarkerLayer}. + // + // All marker APIs not tied to an explicit layer interact with this default + // layer. + // + // Returns a {DisplayMarkerLayer}. + getDefaultMarkerLayer () { + return this.defaultMarkerLayer + } + + /* + Section: Cursors + */ + + // Essential: Get the position of the most recently added cursor in buffer + // coordinates. + // + // Returns a {Point} + getCursorBufferPosition () { + return this.getLastCursor().getBufferPosition() + } + + // Essential: Get the position of all the cursor positions in buffer coordinates. + // + // Returns {Array} of {Point}s in the order they were added + getCursorBufferPositions () { + return this.getCursors().map((cursor) => cursor.getBufferPosition()) + } + + // Essential: Move the cursor to the given position in buffer coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} containing the following keys: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorBufferPosition (position, options) { + return this.moveCursors(cursor => cursor.setBufferPosition(position, options)) + } + + // Essential: Get a {Cursor} at given screen coordinates {Point} + // + // * `position` A {Point} or {Array} of `[row, column]` + // + // Returns the first matched {Cursor} or undefined + getCursorAtScreenPosition (position) { + const selection = this.getSelectionAtScreenPosition(position) + if (selection && selection.getHeadScreenPosition().isEqual(position)) { + return selection.cursor + } + } + + // Essential: Get the position of the most recently added cursor in screen + // coordinates. + // + // Returns a {Point}. + getCursorScreenPosition () { + return this.getLastCursor().getScreenPosition() + } + + // Essential: Get the position of all the cursor positions in screen coordinates. + // + // Returns {Array} of {Point}s in the order the cursors were added + getCursorScreenPositions () { + return this.getCursors().map((cursor) => cursor.getScreenPosition()) + } + + // Essential: Move the cursor to the given position in screen coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorScreenPosition (position, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.moveCursors(cursor => cursor.setScreenPosition(position, options)) + } + + // Essential: Add a cursor at the given position in buffer coordinates. + // + // * `bufferPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtBufferPosition (bufferPosition, options) { + this.selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) + if (!options || options.autoscroll !== false) this.getLastSelection().cursor.autoscroll() + return this.getLastSelection().cursor + } + + // Essential: Add a cursor at the position in screen coordinates. + // + // * `screenPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtScreenPosition (screenPosition, options) { + this.selectionsMarkerLayer.markScreenPosition(screenPosition, {invalidate: 'never'}) + if (!options || options.autoscroll !== false) this.getLastSelection().cursor.autoscroll() + return this.getLastSelection().cursor + } + + // Essential: Returns {Boolean} indicating whether or not there are multiple cursors. + hasMultipleCursors () { + return this.getCursors().length > 1 + } + + // Essential: Move every cursor up one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveUp (lineCount) { + return this.moveCursors(cursor => cursor.moveUp(lineCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor down one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveDown (lineCount) { + return this.moveCursors(cursor => cursor.moveDown(lineCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor left one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveLeft (columnCount) { + return this.moveCursors(cursor => cursor.moveLeft(columnCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor right one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveRight (columnCount) { + return this.moveCursors(cursor => cursor.moveRight(columnCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor to the beginning of its line in buffer coordinates. + moveToBeginningOfLine () { + return this.moveCursors(cursor => cursor.moveToBeginningOfLine()) + } + + // Essential: Move every cursor to the beginning of its line in screen coordinates. + moveToBeginningOfScreenLine () { + return this.moveCursors(cursor => cursor.moveToBeginningOfScreenLine()) + } + + // Essential: Move every cursor to the first non-whitespace character of its line. + moveToFirstCharacterOfLine () { + return this.moveCursors(cursor => cursor.moveToFirstCharacterOfLine()) + } + + // Essential: Move every cursor to the end of its line in buffer coordinates. + moveToEndOfLine () { + return this.moveCursors(cursor => cursor.moveToEndOfLine()) + } + + // Essential: Move every cursor to the end of its line in screen coordinates. + moveToEndOfScreenLine () { + return this.moveCursors(cursor => cursor.moveToEndOfScreenLine()) + } + + // Essential: Move every cursor to the beginning of its surrounding word. + moveToBeginningOfWord () { + return this.moveCursors(cursor => cursor.moveToBeginningOfWord()) + } + + // Essential: Move every cursor to the end of its surrounding word. + moveToEndOfWord () { + return this.moveCursors(cursor => cursor.moveToEndOfWord()) + } + + // Cursor Extended + + // Extended: Move every cursor to the top of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToTop () { + return this.moveCursors(cursor => cursor.moveToTop()) + } + + // Extended: Move every cursor to the bottom of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToBottom () { + return this.moveCursors(cursor => cursor.moveToBottom()) + } + + // Extended: Move every cursor to the beginning of the next word. + moveToBeginningOfNextWord () { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextWord()) + } + + // Extended: Move every cursor to the previous word boundary. + moveToPreviousWordBoundary () { + return this.moveCursors(cursor => cursor.moveToPreviousWordBoundary()) + } + + // Extended: Move every cursor to the next word boundary. + moveToNextWordBoundary () { + return this.moveCursors(cursor => cursor.moveToNextWordBoundary()) + } + + // Extended: Move every cursor to the previous subword boundary. + moveToPreviousSubwordBoundary () { + return this.moveCursors(cursor => cursor.moveToPreviousSubwordBoundary()) + } + + // Extended: Move every cursor to the next subword boundary. + moveToNextSubwordBoundary () { + return this.moveCursors(cursor => cursor.moveToNextSubwordBoundary()) + } + + // Extended: Move every cursor to the beginning of the next paragraph. + moveToBeginningOfNextParagraph () { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextParagraph()) + } + + // Extended: Move every cursor to the beginning of the previous paragraph. + moveToBeginningOfPreviousParagraph () { + return this.moveCursors(cursor => cursor.moveToBeginningOfPreviousParagraph()) + } + + // Extended: Returns the most recently added {Cursor} + getLastCursor () { + this.createLastSelectionIfNeeded() + return _.last(this.cursors) + } + + // Extended: Returns the word surrounding the most recently added cursor. + // + // * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. + getWordUnderCursor (options) { + return this.getTextInBufferRange(this.getLastCursor().getCurrentWordBufferRange(options)) + } + + // Extended: Get an Array of all {Cursor}s. + getCursors () { + this.createLastSelectionIfNeeded() + return this.cursors.slice() + } + + // Extended: Get all {Cursors}s, ordered by their position in the buffer + // instead of the order in which they were added. + // + // Returns an {Array} of {Selection}s. + getCursorsOrderedByBufferPosition () { + return this.getCursors().sort((a, b) => a.compare(b)) + } + + cursorsForScreenRowRange (startScreenRow, endScreenRow) { + const cursors = [] + for (let marker of this.selectionsMarkerLayer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + const cursor = this.cursorsByMarkerId.get(marker.id) + if (cursor) cursors.push(cursor) + } + return cursors + } + + // Add a cursor based on the given {DisplayMarker}. + addCursor (marker) { + const cursor = new Cursor({editor: this, marker, showCursorOnSelection: this.showCursorOnSelection}) + this.cursors.push(cursor) + this.cursorsByMarkerId.set(marker.id, cursor) + return cursor + } + + moveCursors (fn) { + return this.transact(() => { + this.getCursors().forEach(fn) + return this.mergeCursors() + }) + } + + cursorMoved (event) { + return this.emitter.emit('did-change-cursor-position', event) + } + + // Merge cursors that have the same screen position + mergeCursors () { + const positions = {} + for (let cursor of this.getCursors()) { + const position = cursor.getBufferPosition().toString() + if (positions.hasOwnProperty(position)) { + cursor.destroy() + } else { + positions[position] = true + } + } + } + + /* + Section: Selections + */ + + // Essential: Get the selected text of the most recently added selection. + // + // Returns a {String}. + getSelectedText () { + return this.getLastSelection().getText() + } + + // Essential: Get the {Range} of the most recently added selection in buffer + // coordinates. + // + // Returns a {Range}. + getSelectedBufferRange () { + return this.getLastSelection().getBufferRange() + } + + // Essential: Get the {Range}s of all selections in buffer coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedBufferRanges () { + return this.getSelections().map((selection) => selection.getBufferRange()) + } + + // Essential: Set the selected range in buffer coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRange (bufferRange, options) { + return this.setSelectedBufferRanges([bufferRange], options) + } + + // Essential: Set the selected ranges in buffer coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRanges (bufferRanges, options = {}) { + if (!bufferRanges.length) throw new Error('Passed an empty array to setSelectedBufferRanges') + + const selections = this.getSelections() + for (let selection of selections.slice(bufferRanges.length)) { + selection.destroy() + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < bufferRanges.length; i++) { + let bufferRange = bufferRanges[i] + bufferRange = Range.fromObject(bufferRange) + if (selections[i]) { + selections[i].setBufferRange(bufferRange, options) + } else { + this.addSelectionForBufferRange(bufferRange, options) + } + } + }) + } + + // Essential: Get the {Range} of the most recently added selection in screen + // coordinates. + // + // Returns a {Range}. + getSelectedScreenRange () { + return this.getLastSelection().getScreenRange() + } + + // Essential: Get the {Range}s of all selections in screen coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedScreenRanges () { + return this.getSelections().map((selection) => selection.getScreenRange()) + } + + // Essential: Set the selected range in screen coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `screenRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRange (screenRange, options) { + return this.setSelectedBufferRange(this.bufferRangeForScreenRange(screenRange, options), options) + } + + // Essential: Set the selected ranges in screen coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRanges (screenRanges, options = {}) { + if (!screenRanges.length) throw new Error('Passed an empty array to setSelectedScreenRanges') + + const selections = this.getSelections() + for (let selection of selections.slice(screenRanges.length)) { + selection.destroy() + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < screenRanges.length; i++) { + let screenRange = screenRanges[i] + screenRange = Range.fromObject(screenRange) + if (selections[i]) { + selections[i].setScreenRange(screenRange, options) + } else { + this.addSelectionForScreenRange(screenRange, options) + } + } + }) + } + + // Essential: Add a selection for the given range in buffer coordinates. + // + // * `bufferRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // + // Returns the added {Selection}. + addSelectionForBufferRange (bufferRange, options = {}) { + bufferRange = Range.fromObject(bufferRange) + if (!options.preserveFolds) { + this.displayLayer.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) + } + this.selectionsMarkerLayer.markBufferRange(bufferRange, {invalidate: 'never', reversed: options.reversed != null ? options.reversed : false}) + if (options.autoscroll !== false) this.getLastSelection().autoscroll() + return this.getLastSelection() + } + + // Essential: Add a selection for the given range in screen coordinates. + // + // * `screenRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // Returns the added {Selection}. + addSelectionForScreenRange (screenRange, options = {}) { + return this.addSelectionForBufferRange(this.bufferRangeForScreenRange(screenRange), options) + } + + // Essential: Select from the current cursor position to the given position in + // buffer coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToBufferPosition (position) { + const lastSelection = this.getLastSelection() + lastSelection.selectToBufferPosition(position) + return this.mergeIntersectingSelections({reversed: lastSelection.isReversed()}) + } + + // Essential: Select from the current cursor position to the given position in + // screen coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToScreenPosition (position, options) { + const lastSelection = this.getLastSelection() + lastSelection.selectToScreenPosition(position, options) + if (!options || !options.suppressSelectionMerge) { + return this.mergeIntersectingSelections({reversed: lastSelection.isReversed()}) + } + } + + // Essential: Move the cursor of each selection one character upward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectUp (rowCount) { + return this.expandSelectionsBackward(selection => selection.selectUp(rowCount)) + } + + // Essential: Move the cursor of each selection one character downward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectDown (rowCount) { + return this.expandSelectionsForward(selection => selection.selectDown(rowCount)) + } + + // Essential: Move the cursor of each selection one character leftward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectLeft (columnCount) { + return this.expandSelectionsBackward(selection => selection.selectLeft(columnCount)) + } + + // Essential: Move the cursor of each selection one character rightward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectRight (columnCount) { + return this.expandSelectionsForward(selection => selection.selectRight(columnCount)) + } + + // Essential: Select from the top of the buffer to the end of the last selection + // in the buffer. + // + // This method merges multiple selections into a single selection. + selectToTop () { + return this.expandSelectionsBackward(selection => selection.selectToTop()) + } + + // Essential: Selects from the top of the first selection in the buffer to the end + // of the buffer. + // + // This method merges multiple selections into a single selection. + selectToBottom () { + return this.expandSelectionsForward(selection => selection.selectToBottom()) + } + + // Essential: Select all text in the buffer. + // + // This method merges multiple selections into a single selection. + selectAll () { + return this.expandSelectionsForward(selection => selection.selectAll()) + } + + // Essential: Move the cursor of each selection to the beginning of its line + // while preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToBeginningOfLine () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfLine()) + } + + // Essential: Move the cursor of each selection to the first non-whitespace + // character of its line while preserving the selection's tail position. If the + // cursor is already on the first character of the line, move it to the + // beginning of the line. + // + // This method may merge selections that end up intersecting. + selectToFirstCharacterOfLine () { + return this.expandSelectionsBackward(selection => selection.selectToFirstCharacterOfLine()) + } + + // Essential: Move the cursor of each selection to the end of its line while + // preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToEndOfLine () { + return this.expandSelectionsForward(selection => selection.selectToEndOfLine()) + } + + // Essential: Expand selections to the beginning of their containing word. + // + // Operates on all selections. Moves the cursor to the beginning of the + // containing word while preserving the selection's tail position. + selectToBeginningOfWord () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfWord()) + } + + // Essential: Expand selections to the end of their containing word. + // + // Operates on all selections. Moves the cursor to the end of the containing + // word while preserving the selection's tail position. + selectToEndOfWord () { + return this.expandSelectionsForward(selection => selection.selectToEndOfWord()) + } + + // Extended: For each selection, move its cursor to the preceding subword + // boundary while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousSubwordBoundary () { + return this.expandSelectionsBackward(selection => selection.selectToPreviousSubwordBoundary()) + } + + // Extended: For each selection, move its cursor to the next subword boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextSubwordBoundary () { + return this.expandSelectionsForward(selection => selection.selectToNextSubwordBoundary()) + } + + // Essential: For each cursor, select the containing line. + // + // This method merges selections on successive lines. + selectLinesContainingCursors () { + return this.expandSelectionsForward(selection => selection.selectLine()) + } + + // Essential: Select the word surrounding each cursor. + selectWordsContainingCursors () { + return this.expandSelectionsForward(selection => selection.selectWord()) + } + + // Selection Extended + + // Extended: For each selection, move its cursor to the preceding word boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousWordBoundary () { + return this.expandSelectionsBackward(selection => selection.selectToPreviousWordBoundary()) + } + + // Extended: For each selection, move its cursor to the next word boundary while + // maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextWordBoundary () { + return this.expandSelectionsForward(selection => selection.selectToNextWordBoundary()) + } + + // Extended: Expand selections to the beginning of the next word. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // word while preserving the selection's tail position. + selectToBeginningOfNextWord () { + return this.expandSelectionsForward(selection => selection.selectToBeginningOfNextWord()) + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfNextParagraph () { + return this.expandSelectionsForward(selection => selection.selectToBeginningOfNextParagraph()) + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfPreviousParagraph () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfPreviousParagraph()) + } + + // Extended: Select the range of the given marker if it is valid. + // + // * `marker` A {DisplayMarker} + // + // Returns the selected {Range} or `undefined` if the marker is invalid. + selectMarker (marker) { + if (marker.isValid()) { + const range = marker.getBufferRange() + this.setSelectedBufferRange(range) + return range + } + } + + // Extended: Get the most recently added {Selection}. + // + // Returns a {Selection}. + getLastSelection () { + this.createLastSelectionIfNeeded() + return _.last(this.selections) + } + + getSelectionAtScreenPosition (position) { + const markers = this.selectionsMarkerLayer.findMarkers({containsScreenPosition: position}) + if (markers.length > 0) return this.cursorsByMarkerId.get(markers[0].id).selection + } + + // Extended: Get current {Selection}s. + // + // Returns: An {Array} of {Selection}s. + getSelections () { + this.createLastSelectionIfNeeded() + return this.selections.slice() + } + + // Extended: Get all {Selection}s, ordered by their position in the buffer + // instead of the order in which they were added. + // + // Returns an {Array} of {Selection}s. + getSelectionsOrderedByBufferPosition () { + return this.getSelections().sort((a, b) => a.compare(b)) + } + + // Extended: Determine if a given range in buffer coordinates intersects a + // selection. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // + // Returns a {Boolean}. + selectionIntersectsBufferRange (bufferRange) { + return this.getSelections().some(selection => selection.intersectsBufferRange(bufferRange)) + } + + // Selections Private + + // Add a similarly-shaped selection to the next eligible line below + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next following non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionBelow () { + return this.expandSelectionsForward(selection => selection.addSelectionBelow()) + } + + // Add a similarly-shaped selection to the next eligible line above + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next preceding non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionAbove () { + return this.expandSelectionsBackward(selection => selection.addSelectionAbove()) + } + + // Calls the given function with each selection, then merges selections + expandSelectionsForward (fn) { + this.mergeIntersectingSelections(() => this.getSelections().forEach(fn)) + } + + // Calls the given function with each selection, then merges selections in the + // reversed orientation + expandSelectionsBackward (fn) { + this.mergeIntersectingSelections({reversed: true}, () => this.getSelections().forEach(fn)) + } + + finalizeSelections () { + for (let selection of this.getSelections()) { selection.finalize() } + } + + selectionsForScreenRows (startRow, endRow) { + return this.getSelections().filter(selection => selection.intersectsScreenRowRange(startRow, endRow)) + } + + // Merges intersecting selections. If passed a function, it executes + // the function with merging suppressed, then merges intersecting selections + // afterward. + mergeIntersectingSelections (...args) { + return this.mergeSelections(...args, (previousSelection, currentSelection) => { + const exclusive = !currentSelection.isEmpty() && !previousSelection.isEmpty() + return previousSelection.intersectsWith(currentSelection, exclusive) + }) + } + + mergeSelectionsOnSameRows (...args) { + return this.mergeSelections(...args, (previousSelection, currentSelection) => { + const screenRange = currentSelection.getScreenRange() + return previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) + }) + } + + avoidMergingSelections (...args) { + return this.mergeSelections(...args, () => false) + } + + mergeSelections (...args) { + const mergePredicate = args.pop() + let fn = args.pop() + let options = args.pop() + if (typeof fn !== 'function') { + options = fn + fn = () => {} + } + + if (this.suppressSelectionMerging) return fn() + + this.suppressSelectionMerging = true + const result = fn() + this.suppressSelectionMerging = false + + const selections = this.getSelectionsOrderedByBufferPosition() + let lastSelection = selections.shift() + for (const selection of selections) { + if (mergePredicate(lastSelection, selection)) { + lastSelection.merge(selection, options) + } else { + lastSelection = selection + } + } + + return result + } + + // Add a {Selection} based on the given {DisplayMarker}. + // + // * `marker` The {DisplayMarker} to highlight + // * `options` (optional) An {Object} that pertains to the {Selection} constructor. + // + // Returns the new {Selection}. + addSelection (marker, options = {}) { + const cursor = this.addCursor(marker) + let selection = new Selection(Object.assign({editor: this, marker, cursor}, options)) + this.selections.push(selection) + const selectionBufferRange = selection.getBufferRange() + this.mergeIntersectingSelections({preserveFolds: options.preserveFolds}) + + if (selection.destroyed) { + for (selection of this.getSelections()) { + if (selection.intersectsBufferRange(selectionBufferRange)) return selection + } + } else { + this.emitter.emit('did-add-cursor', cursor) + this.emitter.emit('did-add-selection', selection) + return selection + } + } + + // Remove the given selection. + removeSelection (selection) { + _.remove(this.cursors, selection.cursor) + _.remove(this.selections, selection) + this.cursorsByMarkerId.delete(selection.cursor.marker.id) + this.emitter.emit('did-remove-cursor', selection.cursor) + return this.emitter.emit('did-remove-selection', selection) + } + + // Reduce one or more selections to a single empty selection based on the most + // recently added cursor. + clearSelections (options) { + this.consolidateSelections() + this.getLastSelection().clear(options) + } + + // Reduce multiple selections to the least recently added selection. + consolidateSelections () { + const selections = this.getSelections() + if (selections.length > 1) { + for (let selection of selections.slice(1, (selections.length))) { selection.destroy() } + selections[0].autoscroll({center: true}) + return true + } else { + return false + } + } + + // Called by the selection + selectionRangeChanged (event) { + if (this.component) this.component.didChangeSelectionRange() + this.emitter.emit('did-change-selection-range', event) + } + + createLastSelectionIfNeeded () { + if (this.selections.length === 0) { + this.addSelectionForBufferRange([[0, 0], [0, 0]], {autoscroll: false, preserveFolds: true}) + } + } + + /* + Section: Searching and Replacing + */ + + // Essential: Scan regular expression matches in the entire buffer, calling the + // given iterator function on each match. + // + // `::scan` functions as the replace method as well via the `replace` + // + // If you're programmatically modifying the results, you may want to try + // {::backwardsScanInBufferRange} to avoid tripping over your own changes. + // + // * `regex` A {RegExp} to search for. + // * `options` (optional) {Object} + // * `leadingContextLineCount` {Number} default `0`; The number of lines + // before the matched line to include in the results object. + // * `trailingContextLineCount` {Number} default `0`; The number of lines + // after the matched line to include in the results object. + // * `iterator` A {Function} that's called on each match + // * `object` {Object} + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scan (regex, options = {}, iterator) { + if (_.isFunction(options)) { + iterator = options + options = {} + } + + return this.buffer.scan(regex, options, iterator) + } + + // Essential: Scan regular expression matches in a given range, calling the given + // iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scanInBufferRange (regex, range, iterator) { return this.buffer.scanInRange(regex, range, iterator) } + + // Essential: Scan regular expression matches in a given range in reverse order, + // calling the given iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + backwardsScanInBufferRange (regex, range, iterator) { return this.buffer.backwardsScanInRange(regex, range, iterator) } + + /* + Section: Tab Behavior + */ + + // Essential: Returns a {Boolean} indicating whether softTabs are enabled for this + // editor. + getSoftTabs () { return this.softTabs } + + // Essential: Enable or disable soft tabs for this editor. + // + // * `softTabs` A {Boolean} + setSoftTabs (softTabs) { + this.softTabs = softTabs + this.update({softTabs: this.softTabs}) + } + + // Returns a {Boolean} indicating whether atomic soft tabs are enabled for this editor. + hasAtomicSoftTabs () { return this.displayLayer.atomicSoftTabs } + + // Essential: Toggle soft tabs for this editor + toggleSoftTabs () { this.setSoftTabs(!this.getSoftTabs()) } + + // Essential: Get the on-screen length of tab characters. + // + // Returns a {Number}. + getTabLength () { return this.tokenizedBuffer.getTabLength() } + + // Essential: Set the on-screen length of tab characters. Setting this to a + // {Number} This will override the `editor.tabLength` setting. + // + // * `tabLength` {Number} length of a single tab. Setting to `null` will + // fallback to using the `editor.tabLength` config setting + setTabLength (tabLength) { this.update({tabLength}) } + + // Returns an {Object} representing the current invisible character + // substitutions for this editor. See {::setInvisibles}. + getInvisibles () { + if (!this.mini && this.showInvisibles && (this.invisibles != null)) { + return this.invisibles + } else { + return {} + } + } + + doesShowIndentGuide () { return this.showIndentGuide && !this.mini } + + getSoftWrapHangingIndentLength () { return this.displayLayer.softWrapHangingIndent } + + // Extended: Determine if the buffer uses hard or soft tabs. + // + // Returns `true` if the first non-comment line with leading whitespace starts + // with a space character. Returns `false` if it starts with a hard tab (`\t`). + // + // Returns a {Boolean} or undefined if no non-comment lines had leading + // whitespace. + usesSoftTabs () { + for (let bufferRow = 0, end = Math.min(1000, this.buffer.getLastRow()); bufferRow <= end; bufferRow++) { + const tokenizedLine = this.tokenizedBuffer.tokenizedLines[bufferRow] + if (tokenizedLine && tokenizedLine.isComment()) continue + const line = this.buffer.lineForRow(bufferRow) + if (line[0] === ' ') return true + if (line[0] === '\t') return false + } + } + + // Extended: Get the text representing a single level of indent. + // + // If soft tabs are enabled, the text is composed of N spaces, where N is the + // tab length. Otherwise the text is a tab character (`\t`). + // + // Returns a {String}. + getTabText () { return this.buildIndentString(1) } + + // If soft tabs are enabled, convert all hard tabs to soft tabs in the given + // {Range}. + normalizeTabsInBufferRange (bufferRange) { + if (!this.getSoftTabs()) { return } + return this.scanInBufferRange(/\t/g, bufferRange, ({replace}) => replace(this.getTabText())) + } + + /* + Section: Soft Wrap Behavior + */ + + // Essential: Determine whether lines in this editor are soft-wrapped. + // + // Returns a {Boolean}. + isSoftWrapped () { return this.softWrapped } + + // Essential: Enable or disable soft wrapping for this editor. + // + // * `softWrapped` A {Boolean} + // + // Returns a {Boolean}. + setSoftWrapped (softWrapped) { + this.update({softWrapped}) + return this.isSoftWrapped() + } + + getPreferredLineLength () { return this.preferredLineLength } + + // Essential: Toggle soft wrapping for this editor + // + // Returns a {Boolean}. + toggleSoftWrapped () { return this.setSoftWrapped(!this.isSoftWrapped()) } + + // Essential: Gets the column at which column will soft wrap + getSoftWrapColumn () { + if (this.isSoftWrapped() && !this.mini) { + if (this.softWrapAtPreferredLineLength) { + return Math.min(this.getEditorWidthInChars(), this.preferredLineLength) + } else { + return this.getEditorWidthInChars() + } + } else { + return this.maxScreenLineLength + } + } + + /* + Section: Indentation + */ + + // Essential: Get the indentation level of the given buffer row. + // + // Determines how deeply the given row is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // + // Returns a {Number}. + indentationForBufferRow (bufferRow) { + return this.indentLevelForLine(this.lineTextForBufferRow(bufferRow)) + } + + // Essential: Set the indentation level for the given buffer row. + // + // Inserts or removes hard tabs or spaces based on the soft tabs and tab length + // settings of this editor in order to bring it to the given indentation level. + // Note that if soft tabs are enabled and the tab length is 2, a row with 4 + // leading spaces would have an indentation level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // * `newLevel` A {Number} indicating the new indentation level. + // * `options` (optional) An {Object} with the following keys: + // * `preserveLeadingWhitespace` `true` to preserve any whitespace already at + // the beginning of the line (default: false). + setIndentationForBufferRow (bufferRow, newLevel, {preserveLeadingWhitespace} = {}) { + let endColumn + if (preserveLeadingWhitespace) { + endColumn = 0 + } else { + endColumn = this.lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length + } + const newIndentString = this.buildIndentString(newLevel) + return this.buffer.setTextInRange([[bufferRow, 0], [bufferRow, endColumn]], newIndentString) + } + + // Extended: Indent rows intersecting selections by one level. + indentSelectedRows () { + return this.mutateSelectedText(selection => selection.indentSelectedRows()) + } + + // Extended: Outdent rows intersecting selections by one level. + outdentSelectedRows () { + return this.mutateSelectedText(selection => selection.outdentSelectedRows()) + } + + // Extended: Get the indentation level of the given line of text. + // + // Determines how deeply the given line is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `line` A {String} representing a line of text. + // + // Returns a {Number}. + indentLevelForLine (line) { + return this.tokenizedBuffer.indentLevelForLine(line) + } + + // Extended: Indent rows intersecting selections based on the grammar's suggested + // indent level. + autoIndentSelectedRows () { + return this.mutateSelectedText(selection => selection.autoIndentSelectedRows()) + } + + // Indent all lines intersecting selections. See {Selection::indent} for more + // information. + indent (options = {}) { + if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndent() + this.mutateSelectedText(selection => selection.indent(options)) + } + + // Constructs the string used for indents. + buildIndentString (level, column = 0) { + if (this.getSoftTabs()) { + const tabStopViolation = column % this.getTabLength() + return _.multiplyString(' ', Math.floor(level * this.getTabLength()) - tabStopViolation) + } else { + const excessWhitespace = _.multiplyString(' ', Math.round((level - Math.floor(level)) * this.getTabLength())) + return _.multiplyString('\t', Math.floor(level)) + excessWhitespace + } + } + + /* + Section: Grammars + */ + + // Essential: Get the current {Grammar} of this editor. + getGrammar () { + return this.tokenizedBuffer.grammar + } + + // Essential: Set the current {Grammar} of this editor. + // + // Assigning a grammar will cause the editor to re-tokenize based on the new + // grammar. + // + // * `grammar` {Grammar} + setGrammar (grammar) { + return this.tokenizedBuffer.setGrammar(grammar) + } + + // Reload the grammar based on the file name. + reloadGrammar () { + return this.tokenizedBuffer.reloadGrammar() + } + + // Experimental: Get a notification when async tokenization is completed. + onDidTokenize (callback) { + return this.tokenizedBuffer.onDidTokenize(callback) + } + + /* + Section: Managing Syntax Scopes + */ + + // Essential: Returns a {ScopeDescriptor} that includes this editor's language. + // e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with + // {Config::get} to get language specific config values. + getRootScopeDescriptor () { + return this.tokenizedBuffer.rootScopeDescriptor + } + + // Essential: Get the syntactic scopeDescriptor for the given position in buffer + // coordinates. Useful with {Config::get}. + // + // For example, if called with a position inside the parameter list of an + // anonymous CoffeeScript function, the method returns the following array: + // `["source.coffee", "meta.inline.function.coffee", "variable.parameter.function.coffee"]` + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // + // Returns a {ScopeDescriptor}. + scopeDescriptorForBufferPosition (bufferPosition) { + return this.tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) + } + + // Extended: Get the range in buffer coordinates of all tokens surrounding the + // cursor that match the given scope selector. + // + // For example, if you wanted to find the string surrounding the cursor, you + // could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. + // + // * `scopeSelector` {String} selector. e.g. `'.source.ruby'` + // + // Returns a {Range}. + bufferRangeForScopeAtCursor (scopeSelector) { + return this.bufferRangeForScopeAtPosition(scopeSelector, this.getCursorBufferPosition()) + } + + bufferRangeForScopeAtPosition (scopeSelector, position) { + return this.tokenizedBuffer.bufferRangeForScopeAtPosition(scopeSelector, position) + } + + // Extended: Determine if the given row is entirely a comment + isBufferRowCommented (bufferRow) { + const match = this.lineTextForBufferRow(bufferRow).match(/\S/) + if (match) { + if (!this.commentScopeSelector) this.commentScopeSelector = new TextMateScopeSelector('comment.*') + return this.commentScopeSelector.matches(this.scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) + } + } + + // Get the scope descriptor at the cursor. + getCursorScope () { + return this.getLastCursor().getScopeDescriptor() + } + + tokenForBufferPosition (bufferPosition) { + return this.tokenizedBuffer.tokenForPosition(bufferPosition) + } + + /* + Section: Clipboard Operations + */ + + // Essential: For each selection, copy the selected text. + copySelectedText () { + let maintainClipboard = false + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (selection.isEmpty()) { + const previousRange = selection.getBufferRange() + selection.selectLine() + selection.copy(maintainClipboard, true) + selection.setBufferRange(previousRange) + } else { + selection.copy(maintainClipboard, false) + } + maintainClipboard = true + } + } + + // Private: For each selection, only copy highlighted text. + copyOnlySelectedText () { + let maintainClipboard = false + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (!selection.isEmpty()) { + selection.copy(maintainClipboard, false) + maintainClipboard = true + } + } + } + + // Essential: For each selection, cut the selected text. + cutSelectedText () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectLine() + selection.cut(maintainClipboard, true) + } else { + selection.cut(maintainClipboard, false) + } + maintainClipboard = true + }) + } + + // Essential: For each selection, replace the selected text with the contents of + // the clipboard. + // + // If the clipboard contains the same number of selections as the current + // editor, each selection will be replaced with the content of the + // corresponding clipboard selection text. + // + // * `options` (optional) See {Selection::insertText}. + pasteText (options) { + options = Object.assign({}, options) + let {text: clipboardText, metadata} = this.constructor.clipboard.readWithMetadata() + if (!this.emitWillInsertTextEvent(clipboardText)) return false + + if (!metadata) metadata = {} + if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndentOnPaste() + + this.mutateSelectedText((selection, index) => { + let fullLine, indentBasis, text + if (metadata.selections && metadata.selections.length === this.getSelections().length) { + ({text, indentBasis, fullLine} = metadata.selections[index]) + } else { + ({indentBasis, fullLine} = metadata) + text = clipboardText + } + + if (indentBasis != null && (text.includes('\n') || !selection.cursor.hasPrecedingCharactersOnLine())) { + options.indentBasis = indentBasis + } else { + options.indentBasis = null + } + + let range + if (fullLine && selection.isEmpty()) { + const oldPosition = selection.getBufferRange().start + selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]) + range = selection.insertText(text, options) + const newPosition = oldPosition.translate([1, 0]) + selection.setBufferRange([newPosition, newPosition]) + } else { + range = selection.insertText(text, options) + } + + this.emitter.emit('did-insert-text', {text, range}) + }) + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing screen line following the cursor. Otherwise cut the selected + // text. + cutToEndOfLine () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + selection.cutToEndOfLine(maintainClipboard) + maintainClipboard = true + }) + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing buffer line following the cursor. Otherwise cut the + // selected text. + cutToEndOfBufferLine () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + selection.cutToEndOfBufferLine(maintainClipboard) + maintainClipboard = true + }) + } + + /* + Section: Folds + */ + + // Essential: Fold the most recent cursor's row based on its indentation level. + // + // The fold will extend from the nearest preceding line with a lower + // indentation level up to the nearest following row with a lower indentation + // level. + foldCurrentRow () { + const {row} = this.getCursorBufferPosition() + const range = this.tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) + if (range) return this.displayLayer.foldBufferRange(range) + } + + // Essential: Unfold the most recent cursor's row by one level. + unfoldCurrentRow () { + const {row} = this.getCursorBufferPosition() + return this.displayLayer.destroyFoldsContainingBufferPositions([Point(row, Infinity)], false) + } + + // Essential: Fold the given row in buffer coordinates based on its indentation + // level. + // + // If the given row is foldable, the fold will begin there. Otherwise, it will + // begin at the first foldable row preceding the given row. + // + // * `bufferRow` A {Number}. + foldBufferRow (bufferRow) { + let position = Point(bufferRow, Infinity) + while (true) { + const foldableRange = this.tokenizedBuffer.getFoldableRangeContainingPoint(position, this.getTabLength()) + if (foldableRange) { + const existingFolds = this.displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) + if (existingFolds.length === 0) { + this.displayLayer.foldBufferRange(foldableRange) + } else { + const firstExistingFoldRange = this.displayLayer.bufferRangeForFold(existingFolds[0]) + if (firstExistingFoldRange.start.isLessThan(position)) { + position = Point(firstExistingFoldRange.start.row, 0) + continue + } + } + } + break + } + } + + // Essential: Unfold all folds containing the given row in buffer coordinates. + // + // * `bufferRow` A {Number} + unfoldBufferRow (bufferRow) { + const position = Point(bufferRow, Infinity) + return this.displayLayer.destroyFoldsContainingBufferPositions([position]) + } + + // Extended: For each selection, fold the rows it intersects. + foldSelectedLines () { + for (let selection of this.selections) { + selection.fold() + } + } + + // Extended: Fold all foldable lines. + foldAll () { + this.displayLayer.destroyAllFolds() + for (let range of this.tokenizedBuffer.getFoldableRanges(this.getTabLength())) { + this.displayLayer.foldBufferRange(range) + } + } + + // Extended: Unfold all existing folds. + unfoldAll () { + const result = this.displayLayer.destroyAllFolds() + this.scrollToCursorPosition() + return result + } + + // Extended: Fold all foldable lines at the given indent level. + // + // * `level` A {Number}. + foldAllAtIndentLevel (level) { + this.displayLayer.destroyAllFolds() + for (let range of this.tokenizedBuffer.getFoldableRangesAtIndentLevel(level, this.getTabLength())) { + this.displayLayer.foldBufferRange(range) + } + } + + // Extended: Determine whether the given row in buffer coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtBufferRow (bufferRow) { + return this.tokenizedBuffer.isFoldableAtRow(bufferRow) + } + + // Extended: Determine whether the given row in screen coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtScreenRow (screenRow) { + return this.isFoldableAtBufferRow(this.bufferRowForScreenRow(screenRow)) + } + + // Extended: Fold the given buffer row if it isn't currently folded, and unfold + // it otherwise. + toggleFoldAtBufferRow (bufferRow) { + if (this.isFoldedAtBufferRow(bufferRow)) { + return this.unfoldBufferRow(bufferRow) + } else { + return this.foldBufferRow(bufferRow) + } + } + + // Extended: Determine whether the most recently added cursor's row is folded. + // + // Returns a {Boolean}. + isFoldedAtCursorRow () { + return this.isFoldedAtBufferRow(this.getCursorBufferPosition().row) + } + + // Extended: Determine whether the given row in buffer coordinates is folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtBufferRow (bufferRow) { + const range = Range( + Point(bufferRow, 0), + Point(bufferRow, this.buffer.lineLengthForRow(bufferRow)) + ) + return this.displayLayer.foldsIntersectingBufferRange(range).length > 0 + } + + // Extended: Determine whether the given row in screen coordinates is folded. + // + // * `screenRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtScreenRow (screenRow) { + return this.isFoldedAtBufferRow(this.bufferRowForScreenRow(screenRow)) + } + + // Creates a new fold between two row numbers. + // + // startRow - The row {Number} to start folding at + // endRow - The row {Number} to end the fold + // + // Returns the new {Fold}. + foldBufferRowRange (startRow, endRow) { + return this.foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) + } + + foldBufferRange (range) { + return this.displayLayer.foldBufferRange(range) + } + + // Remove any {Fold}s found that intersect the given buffer range. + destroyFoldsIntersectingBufferRange (bufferRange) { + return this.displayLayer.destroyFoldsIntersectingBufferRange(bufferRange) + } + + // Remove any {Fold}s found that contain the given array of buffer positions. + destroyFoldsContainingBufferPositions (bufferPositions, excludeEndpoints) { + return this.displayLayer.destroyFoldsContainingBufferPositions(bufferPositions, excludeEndpoints) + } + + /* + Section: Gutters + */ + + // Essential: Add a custom {Gutter}. + // + // * `options` An {Object} with the following fields: + // * `name` (required) A unique {String} to identify this gutter. + // * `priority` (optional) A {Number} that determines stacking order between + // gutters. Lower priority items are forced closer to the edges of the + // window. (default: -100) + // * `visible` (optional) {Boolean} specifying whether the gutter is visible + // initially after being created. (default: true) + // + // Returns the newly-created {Gutter}. + addGutter (options) { + return this.gutterContainer.addGutter(options) + } + + // Essential: Get this editor's gutters. + // + // Returns an {Array} of {Gutter}s. + getGutters () { + return this.gutterContainer.getGutters() + } + + getLineNumberGutter () { + return this.lineNumberGutter + } + + // Essential: Get the gutter with the given name. + // + // Returns a {Gutter}, or `null` if no gutter exists for the given name. + gutterWithName (name) { + return this.gutterContainer.gutterWithName(name) + } + + /* + Section: Scrolling the TextEditor + */ + + // Essential: Scroll the editor to reveal the most recently added cursor if it is + // off-screen. + // + // * `options` (optional) {Object} + // * `center` Center the editor around the cursor if possible. (default: true) + scrollToCursorPosition (options) { + this.getLastCursor().autoscroll({center: options && options.center !== false}) + } + + // Essential: Scrolls the editor to the given buffer position. + // + // * `bufferPosition` An object that represents a buffer position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToBufferPosition (bufferPosition, options) { + return this.scrollToScreenPosition(this.screenPositionForBufferPosition(bufferPosition), options) + } + + // Essential: Scrolls the editor to the given screen position. + // + // * `screenPosition` An object that represents a screen position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToScreenPosition (screenPosition, options) { + this.scrollToScreenRange(new Range(screenPosition, screenPosition), options) + } + + scrollToTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::scrollToTop instead.') + this.getElement().scrollToTop() + } + + scrollToBottom () { + Grim.deprecate('This is now a view method. Call TextEditorElement::scrollToTop instead.') + this.getElement().scrollToBottom() + } + + scrollToScreenRange (screenRange, options = {}) { + if (options.clip !== false) screenRange = this.clipScreenRange(screenRange) + const scrollEvent = {screenRange, options} + if (this.component) this.component.didRequestAutoscroll(scrollEvent) + this.emitter.emit('did-request-autoscroll', scrollEvent) + } + + getHorizontalScrollbarHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getHorizontalScrollbarHeight instead.') + return this.getElement().getHorizontalScrollbarHeight() + } + + getVerticalScrollbarWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getVerticalScrollbarWidth instead.') + return this.getElement().getVerticalScrollbarWidth() + } + + pageUp () { + this.moveUp(this.getRowsPerPage()) + } + + pageDown () { + this.moveDown(this.getRowsPerPage()) + } + + selectPageUp () { + this.selectUp(this.getRowsPerPage()) + } + + selectPageDown () { + this.selectDown(this.getRowsPerPage()) + } + + // Returns the number of rows per page + getRowsPerPage () { + if (this.component) { + const clientHeight = this.component.getScrollContainerClientHeight() + const lineHeight = this.component.getLineHeight() + return Math.max(1, Math.ceil(clientHeight / lineHeight)) + } else { + return 1 + } + } + + /* + Section: Config + */ + + // Experimental: Supply an object that will provide the editor with settings + // for specific syntactic scopes. See the `ScopedSettingsDelegate` in + // `text-editor-registry.js` for an example implementation. + setScopedSettingsDelegate (scopedSettingsDelegate) { + this.scopedSettingsDelegate = scopedSettingsDelegate + this.tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate + } + + // Experimental: Retrieve the {Object} that provides the editor with settings + // for specific syntactic scopes. + getScopedSettingsDelegate () { return this.scopedSettingsDelegate } + + // Experimental: Is auto-indentation enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndent () { return this.autoIndent } + + // Experimental: Is auto-indentation on paste enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndentOnPaste () { return this.autoIndentOnPaste } + + // Experimental: Does this editor allow scrolling past the last line? + // + // Returns a {Boolean}. + getScrollPastEnd () { + if (this.getAutoHeight()) { + return false + } else { + return this.scrollPastEnd + } + } + + // Experimental: How fast does the editor scroll in response to mouse wheel + // movements? + // + // Returns a positive {Number}. + getScrollSensitivity () { return this.scrollSensitivity } + + // Experimental: Does this editor show cursors while there is a selection? + // + // Returns a positive {Boolean}. + getShowCursorOnSelection () { return this.showCursorOnSelection } + + // Experimental: Are line numbers enabled for this editor? + // + // Returns a {Boolean} + doesShowLineNumbers () { return this.showLineNumbers } + + // Experimental: Get the time interval within which text editing operations + // are grouped together in the editor's undo history. + // + // Returns the time interval {Number} in milliseconds. + getUndoGroupingInterval () { return this.undoGroupingInterval } + + // Experimental: Get the characters that are *not* considered part of words, + // for the purpose of word-based cursor movements. + // + // Returns a {String} containing the non-word characters. + getNonWordCharacters (scopes) { + if (this.scopedSettingsDelegate && this.scopedSettingsDelegate.getNonWordCharacters) { + return this.scopedSettingsDelegate.getNonWordCharacters(scopes) || this.nonWordCharacters + } else { + return this.nonWordCharacters + } + } + + /* + Section: Event Handlers + */ + + handleGrammarChange () { + this.unfoldAll() + return this.emitter.emit('did-change-grammar', this.getGrammar()) + } + + /* + Section: TextEditor Rendering + */ + + // Get the Element for the editor. + getElement () { + if (!this.component) { + if (!TextEditorComponent) TextEditorComponent = require('./text-editor-component') + if (!TextEditorElement) TextEditorElement = require('./text-editor-element') + this.component = new TextEditorComponent({ + model: this, + updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, + initialScrollTopRow: this.initialScrollTopRow, + initialScrollLeftColumn: this.initialScrollLeftColumn + }) + } + return this.component.element + } + + getAllowedLocations () { + return ['center'] + } + + // Essential: Retrieves the greyed out placeholder of a mini editor. + // + // Returns a {String}. + getPlaceholderText () { return this.placeholderText } + + // Essential: Set the greyed out placeholder of a mini editor. Placeholder text + // will be displayed when the editor has no content. + // + // * `placeholderText` {String} text that is displayed when the editor has no content. + setPlaceholderText (placeholderText) { this.update({placeholderText}) } + + pixelPositionForBufferPosition (bufferPosition) { + Grim.deprecate('This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead') + return this.getElement().pixelPositionForBufferPosition(bufferPosition) + } + + pixelPositionForScreenPosition (screenPosition) { + Grim.deprecate('This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead') + return this.getElement().pixelPositionForScreenPosition(screenPosition) + } + + getVerticalScrollMargin () { + const maxScrollMargin = Math.floor(((this.height / this.getLineHeightInPixels()) - 1) / 2) + return Math.min(this.verticalScrollMargin, maxScrollMargin) + } + + setVerticalScrollMargin (verticalScrollMargin) { + this.verticalScrollMargin = verticalScrollMargin + return this.verticalScrollMargin + } + + getHorizontalScrollMargin () { + return Math.min(this.horizontalScrollMargin, Math.floor(((this.width / this.getDefaultCharWidth()) - 1) / 2)) + } + setHorizontalScrollMargin (horizontalScrollMargin) { + this.horizontalScrollMargin = horizontalScrollMargin + return this.horizontalScrollMargin + } + + getLineHeightInPixels () { return this.lineHeightInPixels } + setLineHeightInPixels (lineHeightInPixels) { + this.lineHeightInPixels = lineHeightInPixels + return this.lineHeightInPixels + } + + getKoreanCharWidth () { return this.koreanCharWidth } + getHalfWidthCharWidth () { return this.halfWidthCharWidth } + getDoubleWidthCharWidth () { return this.doubleWidthCharWidth } + getDefaultCharWidth () { return this.defaultCharWidth } + + ratioForCharacter (character) { + if (isKoreanCharacter(character)) { + return this.getKoreanCharWidth() / this.getDefaultCharWidth() + } else if (isHalfWidthCharacter(character)) { + return this.getHalfWidthCharWidth() / this.getDefaultCharWidth() + } else if (isDoubleWidthCharacter(character)) { + return this.getDoubleWidthCharWidth() / this.getDefaultCharWidth() + } else { + return 1 + } + } + + setDefaultCharWidth (defaultCharWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) { + if (doubleWidthCharWidth == null) { doubleWidthCharWidth = defaultCharWidth } + if (halfWidthCharWidth == null) { halfWidthCharWidth = defaultCharWidth } + if (koreanCharWidth == null) { koreanCharWidth = defaultCharWidth } + if (defaultCharWidth !== this.defaultCharWidth || + (doubleWidthCharWidth !== this.doubleWidthCharWidth && + halfWidthCharWidth !== this.halfWidthCharWidth && + koreanCharWidth !== this.koreanCharWidth)) { + this.defaultCharWidth = defaultCharWidth + this.doubleWidthCharWidth = doubleWidthCharWidth + this.halfWidthCharWidth = halfWidthCharWidth + this.koreanCharWidth = koreanCharWidth + if (this.isSoftWrapped()) { + this.displayLayer.reset({ + softWrapColumn: this.getSoftWrapColumn() + }) + } + } + return defaultCharWidth + } + + setHeight (height) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setHeight instead.') + this.getElement().setHeight(height) + } + + getHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getHeight instead.') + return this.getElement().getHeight() + } + + getAutoHeight () { return this.autoHeight != null ? this.autoHeight : true } + + getAutoWidth () { return this.autoWidth != null ? this.autoWidth : false } + + setWidth (width) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setWidth instead.') + this.getElement().setWidth(width) + } + + getWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getWidth instead.') + return this.getElement().getWidth() + } + + // Use setScrollTopRow instead of this method + setFirstVisibleScreenRow (screenRow) { + this.setScrollTopRow(screenRow) + } + + getFirstVisibleScreenRow () { + return this.getElement().component.getFirstVisibleRow() + } + + getLastVisibleScreenRow () { + return this.getElement().component.getLastVisibleRow() + } + + getVisibleRowRange () { + return [this.getFirstVisibleScreenRow(), this.getLastVisibleScreenRow()] + } + + // Use setScrollLeftColumn instead of this method + setFirstVisibleScreenColumn (column) { + return this.setScrollLeftColumn(column) + } + + getFirstVisibleScreenColumn () { + return this.getElement().component.getFirstVisibleColumn() + } + + getScrollTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollTop instead.') + return this.getElement().getScrollTop() + } + + setScrollTop (scrollTop) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollTop instead.') + this.getElement().setScrollTop(scrollTop) + } + + getScrollBottom () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollBottom instead.') + return this.getElement().getScrollBottom() + } + + setScrollBottom (scrollBottom) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollBottom instead.') + this.getElement().setScrollBottom(scrollBottom) + } + + getScrollLeft () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollLeft instead.') + return this.getElement().getScrollLeft() + } + + setScrollLeft (scrollLeft) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollLeft instead.') + this.getElement().setScrollLeft(scrollLeft) + } + + getScrollRight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollRight instead.') + return this.getElement().getScrollRight() + } + + setScrollRight (scrollRight) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollRight instead.') + this.getElement().setScrollRight(scrollRight) + } + + getScrollHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollHeight instead.') + return this.getElement().getScrollHeight() + } + + getScrollWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollWidth instead.') + return this.getElement().getScrollWidth() + } + + getMaxScrollTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getMaxScrollTop instead.') + return this.getElement().getMaxScrollTop() + } + + getScrollTopRow () { + return this.getElement().component.getScrollTopRow() + } + + setScrollTopRow (scrollTopRow) { + this.getElement().component.setScrollTopRow(scrollTopRow) + } + + getScrollLeftColumn () { + return this.getElement().component.getScrollLeftColumn() + } + + setScrollLeftColumn (scrollLeftColumn) { + this.getElement().component.setScrollLeftColumn(scrollLeftColumn) + } + + intersectsVisibleRowRange (startRow, endRow) { + Grim.deprecate('This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.') + return this.getElement().intersectsVisibleRowRange(startRow, endRow) + } + + selectionIntersectsVisibleRowRange (selection) { + Grim.deprecate('This is now a view method. Call TextEditorElement::selectionIntersectsVisibleRowRange instead.') + return this.getElement().selectionIntersectsVisibleRowRange(selection) + } + + screenPositionForPixelPosition (pixelPosition) { + Grim.deprecate('This is now a view method. Call TextEditorElement::screenPositionForPixelPosition instead.') + return this.getElement().screenPositionForPixelPosition(pixelPosition) + } + + pixelRectForScreenRange (screenRange) { + Grim.deprecate('This is now a view method. Call TextEditorElement::pixelRectForScreenRange instead.') + return this.getElement().pixelRectForScreenRange(screenRange) + } + + /* + Section: Utility + */ + + inspect () { + return `` + } + + emitWillInsertTextEvent (text) { + let result = true + const cancel = () => { result = false } + this.emitter.emit('will-insert-text', {cancel, text}) + return result + } + + /* + Section: Language Mode Delegated Methods + */ + + suggestedIndentForBufferRow (bufferRow, options) { + return this.tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) + } + + // Given a buffer row, indent it. + // + // * bufferRow - The row {Number}. + // * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. + autoIndentBufferRow (bufferRow, options) { + const indentLevel = this.suggestedIndentForBufferRow(bufferRow, options) + return this.setIndentationForBufferRow(bufferRow, indentLevel, options) + } + + // Indents all the rows between two buffer row numbers. + // + // * startRow - The row {Number} to start at + // * endRow - The row {Number} to end at + autoIndentBufferRows (startRow, endRow) { + let row = startRow + while (row <= endRow) { + this.autoIndentBufferRow(row) + row++ + } + } + + autoDecreaseIndentForBufferRow (bufferRow) { + const indentLevel = this.tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) + if (indentLevel != null) this.setIndentationForBufferRow(bufferRow, indentLevel) + } + + toggleLineCommentForBufferRow (row) { this.toggleLineCommentsForBufferRows(row, row) } + + toggleLineCommentsForBufferRows (start, end) { + let { + commentStartString, + commentEndString + } = this.tokenizedBuffer.commentStringsForPosition(Point(start, 0)) + if (!commentStartString) return + commentStartString = commentStartString.trim() + + if (commentEndString) { + commentEndString = commentEndString.trim() + const startDelimiterColumnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(start), + commentStartString + ) + if (startDelimiterColumnRange) { + const endDelimiterColumnRange = columnRangeForEndDelimiter( + this.buffer.lineForRow(end), + commentEndString + ) + if (endDelimiterColumnRange) { + this.buffer.transact(() => { + this.buffer.delete([[end, endDelimiterColumnRange[0]], [end, endDelimiterColumnRange[1]]]) + this.buffer.delete([[start, startDelimiterColumnRange[0]], [start, startDelimiterColumnRange[1]]]) + }) + } + } 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) + }) + } + } else { + let hasCommentedLines = false + let hasUncommentedLines = false + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + if (NON_WHITESPACE_REGEXP.test(line)) { + if (columnRangeForStartDelimiter(line, commentStartString)) { + hasCommentedLines = true + } else { + hasUncommentedLines = true + } + } + } + + const shouldUncomment = hasCommentedLines && !hasUncommentedLines + + if (shouldUncomment) { + for (let row = start; row <= end; row++) { + const columnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(row), + commentStartString + ) + if (columnRange) this.buffer.delete([[row, columnRange[0]], [row, columnRange[1]]]) + } + } 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_REGEXP.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_REGEXP.test(line)) { + const indentColumn = columnForIndentLevel(line, minIndentLevel, this.getTabLength()) + this.buffer.insert(Point(row, indentColumn), commentStartString + ' ') + } else { + this.buffer.setTextInRange( + new Range(new Point(row, 0), new Point(row, Infinity)), + indentString + commentStartString + ' ' + ) + } + } + } + } + } + + rowRangeForParagraphAtBufferRow (bufferRow) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(bufferRow))) return + + const isCommented = this.tokenizedBuffer.isRowCommented(bufferRow) + + let startRow = bufferRow + while (startRow > 0) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(startRow - 1))) break + if (this.tokenizedBuffer.isRowCommented(startRow - 1) !== isCommented) break + startRow-- + } + + let endRow = bufferRow + const rowCount = this.getLineCount() + while (endRow < rowCount) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(endRow + 1))) break + if (this.tokenizedBuffer.isRowCommented(endRow + 1) !== isCommented) break + endRow++ + } + + return new Range(new Point(startRow, 0), new Point(endRow, this.buffer.lineLengthForRow(endRow))) + } +} + +function columnForIndentLevel (line, indentLevel, 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 +} + +function columnRangeForStartDelimiter (line, delimiter) { + const startColumn = line.search(NON_WHITESPACE_REGEXP) + if (startColumn === -1) return null + if (!line.startsWith(delimiter, startColumn)) return null + + let endColumn = startColumn + delimiter.length + if (line[endColumn] === ' ') endColumn++ + return [startColumn, endColumn] +} + +function columnRangeForEndDelimiter (line, delimiter) { + let startColumn = line.lastIndexOf(delimiter) + if (startColumn === -1) return null + + const endColumn = startColumn + delimiter.length + if (NON_WHITESPACE_REGEXP.test(line.slice(endColumn))) return null + if (line[startColumn - 1] === ' ') startColumn-- + return [startColumn, endColumn] +} + +class ChangeEvent { + constructor ({oldRange, newRange}) { + this.oldRange = oldRange + this.newRange = newRange + } + + get start () { + return this.newRange.start + } + + get oldExtent () { + return this.oldRange.getExtent() + } + + get newExtent () { + return this.newRange.getExtent() + } +} diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee deleted file mode 100644 index d5a2cb0d1..000000000 --- a/src/theme-manager.coffee +++ /dev/null @@ -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) diff --git a/src/theme-manager.js b/src/theme-manager.js new file mode 100644 index 000000000..6abf0fc74 --- /dev/null +++ b/src/theme-manager.js @@ -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)) + } +} diff --git a/src/theme-package.coffee b/src/theme-package.coffee deleted file mode 100644 index 053132d61..000000000 --- a/src/theme-package.coffee +++ /dev/null @@ -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) diff --git a/src/theme-package.js b/src/theme-package.js new file mode 100644 index 000000000..7ac01bd97 --- /dev/null +++ b/src/theme-package.js @@ -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 + } +} diff --git a/src/tokenized-buffer.js b/src/tokenized-buffer.js index b4bc0d41c..2a9446256 100644 --- a/src/tokenized-buffer.js +++ b/src/tokenized-buffer.js @@ -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]) { diff --git a/src/tooltip-manager.coffee b/src/tooltip-manager.coffee deleted file mode 100644 index 1a9b6fe44..000000000 --- a/src/tooltip-manager.coffee +++ /dev/null @@ -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 - "#{humanizeKeystrokes(bindings[0].keystrokes)}" diff --git a/src/tooltip-manager.js b/src/tooltip-manager.js new file mode 100644 index 000000000..937f831d1 --- /dev/null +++ b/src/tooltip-manager.js @@ -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 `${humanizeKeystrokes(bindings[0].keystrokes)}` + } +} diff --git a/src/view-registry.coffee b/src/view-registry.coffee deleted file mode 100644 index f300cc031..000000000 --- a/src/view-registry.coffee +++ /dev/null @@ -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?() diff --git a/src/view-registry.js b/src/view-registry.js new file mode 100644 index 000000000..87bf8620f --- /dev/null +++ b/src/view-registry.js @@ -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() } + } +} diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee deleted file mode 100644 index 6a277b612..000000000 --- a/src/window-event-handler.coffee +++ /dev/null @@ -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) diff --git a/src/window-event-handler.js b/src/window-event-handler.js new file mode 100644 index 000000000..6d380819b --- /dev/null +++ b/src/window-event-handler.js @@ -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) + } +} diff --git a/src/workspace.js b/src/workspace.js index 80dfc47cb..defb43df0 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -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 diff --git a/static/jasmine.less b/static/jasmine.less index ab2695179..dcd467c71 100644 --- a/static/jasmine.less +++ b/static/jasmine.less @@ -165,6 +165,7 @@ body { font-weight: bold; color: #d9534f; padding: 5px 0 5px 0; + white-space: pre-wrap; } .result-message.deprecation-message {