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 777e96513..6ca73f8ce 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.7.1", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -91,10 +91,10 @@ "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.8", + "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", @@ -112,63 +112,63 @@ "github": "0.7.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", "line-ending-selector": "0.7.4", "link": "0.31.3", - "markdown-preview": "0.159.15", + "markdown-preview": "0.159.17", "metrics": "1.2.6", "notifications": "0.69.2", "open-on-github": "1.2.1", "package-generator": "1.1.1", - "settings-view": "0.252.0", - "snippets": "1.1.5", + "settings-view": "0.252.2", + "snippets": "1.1.7", "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.108.0", "timecop": "0.36.0", - "tree-view": "0.220.0", + "tree-view": "0.221.0", "update-package-dependencies": "0.12.0", "welcome": "0.36.5", "whitespace": "0.37.4", "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.2", "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.5", + "language-javascript": "0.127.6", "language-json": "0.19.1", - "language-less": "0.33.0", + "language-less": "0.34.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.38.1", - "language-php": "0.42.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-sass": "0.61.2", + "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/selection-spec.coffee b/spec/selection-spec.coffee index cb070310a..b0e65be30 100644 --- a/spec/selection-spec.coffee +++ b/spec/selection-spec.coffee @@ -103,6 +103,11 @@ describe "Selection", -> 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]]) 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..5f0a28883 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 diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 53011fdcc..b8d4bdcf9 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -1871,7 +1871,7 @@ describe "TextEditor", -> expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] describe "when the 'preserveFolds' option is false (the default)", -> - it "removes folds that contain the selections", -> + 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) @@ -1884,6 +1884,9 @@ describe "TextEditor", -> 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]]) @@ -4222,6 +4225,19 @@ describe "TextEditor", -> 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", -> @@ -4363,108 +4379,6 @@ describe "TextEditor", -> 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") diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index c81df8089..d10efa695 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -2,6 +2,8 @@ const fs = require('fs') const temp = require('temp').track() const {Point, Range} = require('text-buffer') const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const TextBuffer = require('text-buffer') +const TextEditor = require('../src/text-editor') describe('TextEditor', () => { let editor @@ -58,6 +60,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 +445,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}) 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/command-registry.js b/src/command-registry.js index 30089b7f1..ba75918ab 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 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/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 index 4d3fe8882..cb45286b8 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -87,7 +87,7 @@ class Selection extends Model setBufferRange: (bufferRange, options={}) -> bufferRange = Range.fromObject(bufferRange) options.reversed ?= @isReversed() - @editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds + @editor.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) unless options.preserveFolds @modifySelection => needsFlash = options.flash delete options.flash if options.flash? @@ -356,13 +356,19 @@ class Selection extends Model # # * `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 + # * `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. + # * `undo` If `skip`, skips the undo stack for this operation. insertText: (text, options={}) -> oldBufferRange = @getBufferRange() wasReversed = @isReversed() @@ -373,7 +379,7 @@ class Selection extends Model remainingLines = text.split('\n') firstInsertedLine = remainingLines.shift() - if options.indentBasis? + if options.indentBasis? and not options.preserveTrailingLineIndentation indentAdjustment = @editor.indentLevelForLine(precedingText) - options.indentBasis @adjustIndent(remainingLines, indentAdjustment) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 5ff96eec5..f19b7e31c 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -804,7 +804,15 @@ class TextEditorComponent { key: overlayProps.element, overlayComponents: this.overlayComponents, measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element), - didResize: () => { this.updateSync() } + didResize: (overlayComponent) => { + this.updateOverlayToRender(overlayProps) + overlayComponent.update(Object.assign( + { + measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element) + }, + overlayProps + )) + } }, overlayProps )) @@ -1339,42 +1347,47 @@ 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() + 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) + } + 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) } } @@ -1758,7 +1771,7 @@ class TextEditorComponent { if (target && target.matches('.fold-marker')) { const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) - model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition)) + model.destroyFoldsContainingBufferPositions([bufferPosition], false) return } @@ -4202,7 +4215,7 @@ class OverlayComponent { const {contentRect} = entries[0] if (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) { this.resizeObserver.disconnect() - this.props.didResize() + this.props.didResize(this) process.nextTick(() => { this.resizeObserver.observe(this.props.element) }) } }) @@ -4215,15 +4228,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-utils.js b/src/text-editor-utils.js new file mode 100644 index 000000000..ab1104144 --- /dev/null +++ b/src/text-editor-utils.js @@ -0,0 +1,139 @@ +// This file is temporary. We should gradually convert methods in `text-editor.coffee` +// from CoffeeScript to JavaScript and move them here, so that we can eventually convert +// the entire class to JavaScript. + +const {Point, Range} = require('text-buffer') + +const NON_WHITESPACE_REGEX = /\S/ + +module.exports = { + 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_REGEX.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_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 = 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 + ' ' + ) + } + } + } + } + } +} + +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_REGEX) + 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_REGEX.test(line.slice(endColumn))) return null + if (line[startColumn - 1] === ' ') startColumn-- + return [startColumn, endColumn] +} diff --git a/src/text-editor.coffee b/src/text-editor.coffee index c00508f09..79e00e31a 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -9,6 +9,8 @@ TokenizedBuffer = require './tokenized-buffer' Cursor = require './cursor' Model = require './model' Selection = require './selection' +TextEditorUtils = require './text-editor-utils' + TextMateScopeSelector = require('first-mate').ScopeSelector GutterContainer = require './gutter-container' TextEditorComponent = null @@ -123,6 +125,8 @@ class TextEditor extends Model Object.defineProperty(@prototype, 'languageMode', get: -> @tokenizedBuffer) + Object.assign(@prototype, TextEditorUtils) + @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? @@ -2495,8 +2499,9 @@ class TextEditor extends Model # # Returns the added {Selection}. addSelectionForBufferRange: (bufferRange, options={}) -> + bufferRange = Range.fromObject(bufferRange) unless options.preserveFolds - @destroyFoldsIntersectingBufferRange(bufferRange) + @displayLayer.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) @selectionsMarkerLayer.markBufferRange(bufferRange, {invalidate: 'never', reversed: options.reversed ? false}) @getLastSelection().autoscroll() unless options.autoscroll is false @getLastSelection() @@ -3247,12 +3252,13 @@ class TextEditor extends Model # corresponding clipboard selection text. # # * `options` (optional) See {Selection::insertText}. - pasteText: (options={}) -> + pasteText: (options) -> + options = Object.assign({}, options) {text: clipboardText, metadata} = @constructor.clipboard.readWithMetadata() return false unless @emitWillInsertTextEvent(clipboardText) metadata ?= {} - options.autoIndent = @shouldAutoIndentOnPaste() + options.autoIndent ?= @shouldAutoIndentOnPaste() @mutateSelectedText (selection, index) => if metadata.selections?.length is @getSelections().length @@ -3310,14 +3316,13 @@ class TextEditor extends Model # level. foldCurrentRow: -> {row} = @getCursorBufferPosition() - range = @tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) - @displayLayer.foldBufferRange(range) + if 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)) + @displayLayer.destroyFoldsContainingBufferPositions([Point(row, Infinity)], false) # Essential: Fold the given row in buffer coordinates based on its indentation # level. @@ -3346,7 +3351,7 @@ class TextEditor extends Model # * `bufferRow` A {Number} unfoldBufferRow: (bufferRow) -> position = Point(bufferRow, Infinity) - @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) + @displayLayer.destroyFoldsContainingBufferPositions([position]) # Extended: For each selection, fold the rows it intersects. foldSelectedLines: -> @@ -3445,6 +3450,10 @@ class TextEditor extends Model destroyFoldsIntersectingBufferRange: (bufferRange) -> @displayLayer.destroyFoldsIntersectingBufferRange(bufferRange) + # Remove any {Fold}s found that contain the given array of buffer positions. + destroyFoldsContainingBufferPositions: (bufferPositions, excludeEndpoints) -> + @displayLayer.destroyFoldsContainingBufferPositions(bufferPositions, excludeEndpoints) + ### Section: Gutters ### @@ -3621,9 +3630,6 @@ class TextEditor extends Model getNonWordCharacters: (scopes) -> @scopedSettingsDelegate?.getNonWordCharacters?(scopes) ? @nonWordCharacters - getCommentStrings: (scopes) -> - @scopedSettingsDelegate?.getCommentStrings?(scopes) - ### Section: Event Handlers ### @@ -3886,8 +3892,6 @@ class TextEditor extends Model toggleLineCommentForBufferRow: (row) -> @toggleLineCommentsForBufferRows(row, row) - toggleLineCommentsForBufferRows: (start, end) -> @tokenizedBuffer.toggleLineCommentsForBufferRows(start, end) - rowRangeForParagraphAtBufferRow: (bufferRow) -> return unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(bufferRow)) 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