diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1bd76263..d9b837174 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,7 @@ in the proper package's repository. * Beware of platform differences * Use `require('atom').fs.getHomeDirectory()` to get the home directory. * Use `path.join()` to concatenate filenames. - * Temporary directory is not `/tmp` on Windows, use `os.tmpdir()` when - possible + * Use `os.tmpdir()` instead of `/tmp ## Git Commit Messages * Use the present tense @@ -57,6 +56,12 @@ in the proper package's repository. * :non-potable_water: `:non-potable_water:` when plugging memory leaks * :memo: `:memo:` when writing docs * :penguin: `:penguin:` when fixing something on Linux + * :apple: `:apple:` when fixing something on Mac OS + * :bug: `:bug:` when fixing a bug + * :fire: `:fire:` when removing code or files + * :green_heart: `:green_heart:` when fixing the CI build + * :white_check_mark: `:white_check_mark:` when adding tests + * :lock: `:lock:` when dealing with security ## CoffeeScript Styleguide diff --git a/apm/package.json b/apm/package.json index 59a334e2b..bb15222bc 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "0.53.0" + "atom-package-manager": "0.54.0" } } diff --git a/atom.sh b/atom.sh index 840c0364a..ddeba08d9 100755 --- a/atom.sh +++ b/atom.sh @@ -70,16 +70,18 @@ elif [ $OS == 'Linux' ]; then USR_DIRECTORY=$(readlink -f $(dirname $SCRIPT)/..) ATOM_PATH="$USR_DIRECTORY/share/atom/atom" - [ -x "$ATOM_PATH" ] || ATOM_PATH='/tmp/atom-build/Atom/atom' + : ${TMPDIR:=/tmp} + + [ -x "$ATOM_PATH" ] || ATOM_PATH="$TMPDIR/atom-build/Atom/atom" if [ $EXPECT_OUTPUT ]; then "$ATOM_PATH" --executed-from="$(pwd)" --pid=$$ "$@" exit $? else ( - nohup "$ATOM_PATH" --executed-from="$(pwd)" --pid=$$ "$@" > /tmp/atom-nohup.out 2>&1 + nohup "$ATOM_PATH" --executed-from="$(pwd)" --pid=$$ "$@" > "$TMPDIR/atom-nohup.out" 2>&1 if [ $? -ne 0 ]; then - cat /tmp/atom-nohup.out + cat "$TMPDIR/atom-nohup.out" exit $? fi ) & diff --git a/build/Gruntfile.coffee b/build/Gruntfile.coffee index 7ef1157fd..337c4833b 100644 --- a/build/Gruntfile.coffee +++ b/build/Gruntfile.coffee @@ -36,35 +36,23 @@ module.exports = (grunt) -> grunt.log.write = (args...) -> grunt.log [major, minor, patch] = packageJson.version.split('.') + tmpDir = os.tmpdir() + appName = if process.platform is 'darwin' then 'Atom.app' else 'Atom' + buildDir = grunt.option('build-dir') ? path.join(tmpDir, 'atom-build') + atomShellDownloadDir = path.join(os.tmpdir(), 'atom-cached-atom-shells') + symbolsDir = path.join(buildDir, 'Atom.breakpad.syms') + shellAppDir = path.join(buildDir, appName) if process.platform is 'win32' - appName = 'Atom' - tmpDir = os.tmpdir() - buildDir = grunt.option('build-dir') ? path.join(tmpDir, 'atom-build') - symbolsDir = path.join(buildDir, 'Atom.breakpad.syms') - shellAppDir = path.join(buildDir, appName) contentsDir = shellAppDir appDir = path.join(shellAppDir, 'resources', 'app') - atomShellDownloadDir = path.join(os.tmpdir(), 'atom-cached-atom-shells') installDir = path.join(process.env.ProgramFiles, appName) else if process.platform is 'darwin' - appName = 'Atom.app' - tmpDir = '/tmp' - buildDir = grunt.option('build-dir') ? path.join(tmpDir, 'atom-build') - symbolsDir = path.join(buildDir, 'Atom.breakpad.syms') - shellAppDir = path.join(buildDir, appName) contentsDir = path.join(shellAppDir, 'Contents') appDir = path.join(contentsDir, 'Resources', 'app') - atomShellDownloadDir = '/tmp/atom-cached-atom-shells' installDir = path.join('/Applications', appName) else - appName = 'Atom' - tmpDir = '/tmp' - buildDir = grunt.option('build-dir') ? path.join(tmpDir, 'atom-build') - symbolsDir = path.join(buildDir, 'Atom.breakpad.syms') - shellAppDir = path.join(buildDir, appName) contentsDir = shellAppDir appDir = path.join(shellAppDir, 'resources', 'app') - atomShellDownloadDir = '/tmp/atom-cached-atom-shells' installDir = process.env.INSTALL_PREFIX ? '/usr/local' coffeeConfig = diff --git a/build/tasks/clean-task.coffee b/build/tasks/clean-task.coffee index 1d0befa3f..97f0a9105 100644 --- a/build/tasks/clean-task.coffee +++ b/build/tasks/clean-task.coffee @@ -5,7 +5,7 @@ module.exports = (grunt) -> {rm} = require('./task-helpers')(grunt) grunt.registerTask 'partial-clean', 'Delete some of the build files', -> - tmpdir = if process.platform is 'win32' then os.tmpdir() else '/tmp' + tmpdir = os.tmpdir() rm grunt.config.get('atom.buildDir') rm require('../src/coffee-cache').cacheDir diff --git a/docs/advanced/keymaps.md b/docs/advanced/keymaps.md index 723369253..5b7c5392d 100644 --- a/docs/advanced/keymaps.md +++ b/docs/advanced/keymaps.md @@ -10,8 +10,8 @@ keystrokes pass through elements with the class `.editor`: ```coffee '.editor': - 'cmd-delete': 'editor:backspace-to-beginning-of-line' - 'alt-backspace': 'editor:backspace-to-beginning-of-word' + 'cmd-delete': 'editor:delete-to-beginning-of-line' + 'alt-backspace': 'editor:delete-to-beginning-of-word' 'ctrl-A': 'editor:select-to-first-character-of-line' 'ctrl-shift-e': 'editor:select-to-end-of-line' 'cmd-left': 'editor:move-to-first-character-of-line' @@ -24,7 +24,7 @@ keystrokes pass through elements with the class `.editor`: Beneath the first selector are several bindings, mapping specific *keystroke patterns* to *commands*. When an element with the `.editor` class is focused and `cmd-delete` is pressed, an custom DOM event called -`editor:backspace-to-beginning-of-line` is emitted on the `.editor` element. +`editor:delete-to-beginning-of-line` is emitted on the `.editor` element. The second selector group also targets editors, but only if they don't have the `.mini` class. In this example, the commands for code folding don't really make diff --git a/docs/build-instructions/freebsd.md b/docs/build-instructions/freebsd.md index 2299d9cab..147c9da23 100644 --- a/docs/build-instructions/freebsd.md +++ b/docs/build-instructions/freebsd.md @@ -15,7 +15,7 @@ FreeBSD -RELEASE 64-bit is the recommended platform. ```sh git clone https://github.com/atom/atom cd atom - script/build # Creates application at /tmp/atom-build/Atom + script/build # Creates application at $TMPDIR/atom-build/Atom sudo script/grunt install # Installs command to /usr/local/bin/atom ``` diff --git a/docs/build-instructions/linux.md b/docs/build-instructions/linux.md index 359c780b9..259ef71d5 100644 --- a/docs/build-instructions/linux.md +++ b/docs/build-instructions/linux.md @@ -15,9 +15,9 @@ Ubuntu LTS 12.04 64-bit is the recommended platform. ```sh git clone https://github.com/atom/atom cd atom - script/build # Creates application at /tmp/atom-build/Atom + script/build # Creates application at $TMPDIR/atom-build/Atom sudo script/grunt install # Installs command to /usr/local/bin/atom - script/grunt mkdeb # Generates a .deb package at /tmp/atom-build + script/grunt mkdeb # Generates a .deb package at $TMPDIR/atom-build ``` ## Troubleshooting diff --git a/docs/build-instructions/windows.md b/docs/build-instructions/windows.md index 783ebac2f..8652a291e 100644 --- a/docs/build-instructions/windows.md +++ b/docs/build-instructions/windows.md @@ -13,6 +13,7 @@ ```bat # Use the `Git Shell` app which was installed by GitHub for Windows. Also Make # sure you have logged into the GitHub for Windows GUI App. + cd C:\ git clone https://github.com/atom/atom/ cd atom script\build diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index 221dcfe09..cf37a5e8f 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -103,16 +103,16 @@ 'alt-shift-right': 'editor:select-to-end-of-word' # Apple Specific - 'cmd-backspace': 'editor:backspace-to-beginning-of-line' - 'cmd-shift-backspace': 'editor:backspace-to-beginning-of-line' - 'cmd-delete': 'editor:backspace-to-beginning-of-line' + 'cmd-backspace': 'editor:delete-to-beginning-of-line' + 'cmd-shift-backspace': 'editor:delete-to-beginning-of-line' + 'cmd-delete': 'editor:delete-to-beginning-of-line' 'ctrl-A': 'editor:select-to-first-character-of-line' 'ctrl-E': 'editor:select-to-end-of-line' 'cmd-left': 'editor:move-to-first-character-of-line' 'cmd-right': 'editor:move-to-end-of-screen-line' 'cmd-shift-left': 'editor:select-to-first-character-of-line' 'cmd-shift-right': 'editor:select-to-end-of-line' - 'alt-backspace': 'editor:backspace-to-beginning-of-word' + 'alt-backspace': 'editor:delete-to-beginning-of-word' 'alt-delete': 'editor:delete-to-end-of-word' 'ctrl-a': 'editor:move-to-beginning-of-line' 'ctrl-e': 'editor:move-to-end-of-line' diff --git a/keymaps/emacs.cson b/keymaps/emacs.cson index 11ce62987..78463671c 100644 --- a/keymaps/emacs.cson +++ b/keymaps/emacs.cson @@ -3,5 +3,5 @@ 'alt-F': 'editor:select-to-end-of-word' 'alt-b': 'editor:move-to-beginning-of-word' 'alt-B': 'editor:select-to-beginning-of-word' - 'alt-h': 'editor:backspace-to-beginning-of-word' + 'alt-h': 'editor:delete-to-beginning-of-word' 'alt-d': 'editor:delete-to-end-of-word' diff --git a/keymaps/linux.cson b/keymaps/linux.cson index 2f3df3dd7..dd4fd5275 100644 --- a/keymaps/linux.cson +++ b/keymaps/linux.cson @@ -65,10 +65,12 @@ 'ctrl-right': 'editor:move-to-end-of-word' 'ctrl-shift-left': 'editor:select-to-beginning-of-word' 'ctrl-shift-right': 'editor:select-to-end-of-word' - 'ctrl-backspace': 'editor:backspace-to-beginning-of-word' + 'ctrl-backspace': 'editor:delete-to-beginning-of-word' 'ctrl-delete': 'editor:delete-to-end-of-word' 'ctrl-home': 'core:move-to-top' 'ctrl-end': 'core:move-to-bottom' + 'ctrl-shift-home': 'core:select-to-top' + 'ctrl-shift-end': 'core:select-to-bottom' # Sublime Parity 'ctrl-a': 'core:select-all' diff --git a/keymaps/win32.cson b/keymaps/win32.cson index c1dafad57..158cf4a6f 100644 --- a/keymaps/win32.cson +++ b/keymaps/win32.cson @@ -68,7 +68,7 @@ 'ctrl-right': 'editor:move-to-end-of-word' 'ctrl-shift-left': 'editor:select-to-beginning-of-word' 'ctrl-shift-right': 'editor:select-to-end-of-word' - 'ctrl-backspace': 'editor:backspace-to-beginning-of-word' + 'ctrl-backspace': 'editor:delete-to-beginning-of-word' 'ctrl-delete': 'editor:delete-to-end-of-word' 'ctrl-home': 'core:move-to-top' 'ctrl-end': 'core:move-to-bottom' diff --git a/package.json b/package.json index 4254e3746..bcee11726 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "0.96.0", + "version": "0.97.0", "description": "A hackable text editor for the 21st Century.", "main": "./src/browser/main.js", "repository": { @@ -17,7 +17,7 @@ "url": "http://github.com/atom/atom/raw/master/LICENSE.md" } ], - "atomShellVersion": "0.12.4", + "atomShellVersion": "0.12.5", "dependencies": { "async": "0.2.6", "atom-keymap": "^0.19.0", @@ -58,7 +58,7 @@ "temp": "0.5.0", "text-buffer": "^2.2.2", "theorist": "^1", - "underscore-plus": "^1.2.1", + "underscore-plus": "^1.2.2", "vm-compatibility-layer": "0.1.0" }, "packageDependencies": { @@ -70,21 +70,21 @@ "solarized-dark-syntax": "0.14.0", "solarized-light-syntax": "0.7.0", "archive-view": "0.31.0", - "autocomplete": "0.27.0", + "autocomplete": "0.28.0", "autoflow": "0.17.0", "autosave": "0.13.0", "background-tips": "0.13.0", "bookmarks": "0.22.0", - "bracket-matcher": "0.33.0", + "bracket-matcher": "0.36.0", "command-palette": "0.21.0", "deprecation-cop": "0.5.0", "dev-live-reload": "0.30.0", "exception-reporting": "0.17.0", "feedback": "0.33.0", - "find-and-replace": "0.101.0", - "fuzzy-finder": "0.50.0", + "find-and-replace": "0.106.0", + "fuzzy-finder": "0.51.0", "git-diff": "0.28.0", - "go-to-line": "0.19.0", + "go-to-line": "0.21.0", "grammar-selector": "0.26.0", "image-view": "0.33.0", "keybinding-resolver": "0.17.0", @@ -93,16 +93,16 @@ "metrics": "0.32.0", "open-on-github": "0.28.0", "package-generator": "0.30.0", - "release-notes": "0.29.0", - "settings-view": "0.114.0", + "release-notes": "0.31.0", + "settings-view": "0.115.0", "snippets": "0.43.0", "spell-check": "0.35.0", "status-bar": "0.40.0", "styleguide": "0.29.0", "symbols-view": "0.52.0", - "tabs": "0.39.0", + "tabs": "0.40.0", "timecop": "0.18.0", - "tree-view": "0.92.0", + "tree-view": "0.93.0", "update-package-dependencies": "0.6.0", "welcome": "0.14.0", "whitespace": "0.22.0", @@ -110,10 +110,10 @@ "language-c": "0.15.0", "language-coffee-script": "0.22.0", "language-css": "0.16.0", - "language-gfm": "0.37.0", + "language-gfm": "0.38.0", "language-git": "0.9.0", - "language-go": "0.11.0", - "language-html": "0.21.0", + "language-go": "0.12.0", + "language-html": "0.22.0", "language-hyperlink": "0.9.0", "language-java": "0.10.0", "language-javascript": "0.26.0", @@ -124,17 +124,17 @@ "language-perl": "0.8.0", "language-php": "0.14.0", "language-property-list": "0.7.0", - "language-python": "0.15.0", - "language-ruby": "0.24.0", + "language-python": "0.17.0", + "language-ruby": "0.25.0", "language-ruby-on-rails": "0.14.0", - "language-sass": "0.10.0", + "language-sass": "0.11.0", "language-shellscript": "0.8.0", "language-source": "0.7.0", "language-sql": "0.8.0", "language-text": "0.6.0", "language-todo": "0.10.0", "language-toml": "0.12.0", - "language-xml": "0.12.0", + "language-xml": "0.13.0", "language-yaml": "0.6.0" }, "private": true, diff --git a/resources/win/atom.ico b/resources/win/atom.ico index 446140277..57d2c7aca 100644 Binary files a/resources/win/atom.ico and b/resources/win/atom.ico differ diff --git a/script/clean b/script/clean index e55668215..2ee2390e7 100755 --- a/script/clean +++ b/script/clean @@ -8,7 +8,7 @@ var productName = require('../package.json').productName; process.chdir(path.dirname(__dirname)); var home = process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME']; -var tmpdir = process.platform === 'win32' ? os.tmpdir() : '/tmp'; +var tmpdir = os.tmpdir(); // Windows: Use START as a way to ignore error if Atom.exe isnt running var killatom = process.platform === 'win32' ? 'START taskkill /F /IM ' + productName + '.exe' : 'pkill -9 ' + productName + ' || true'; diff --git a/script/mkdeb b/script/mkdeb index 0bcc84926..f5c14203d 100755 --- a/script/mkdeb +++ b/script/mkdeb @@ -1,6 +1,8 @@ #!/bin/bash # mkdeb version control-file-path deb-file-path +set -e + SCRIPT=`readlink -f "$0"` ROOT=`readlink -f $(dirname $SCRIPT)/..` cd $ROOT @@ -12,6 +14,7 @@ ICON_FILE="$4" DEB_PATH="$5" TARGET_ROOT="`mktemp -d`" +chmod 755 "$TARGET_ROOT" TARGET="$TARGET_ROOT/atom-$VERSION-amd64" mkdir -p "$TARGET/usr" diff --git a/spec/atom-spec.coffee b/spec/atom-spec.coffee index d1ee823ed..d2e6392af 100644 --- a/spec/atom-spec.coffee +++ b/spec/atom-spec.coffee @@ -510,20 +510,20 @@ describe "the `atom` global", -> # enabling of theme pack = atom.packages.enablePackage(packageName) - activatedPackages = null waitsFor -> - activatedPackages = atom.packages.getActivePackages() - activatedPackages.length > 0 + pack in atom.packages.getActivePackages() runs -> - expect(activatedPackages).toContain(pack) expect(atom.config.get('core.themes')).toContain packageName expect(atom.config.get('core.disabledPackages')).not.toContain packageName # disabling of theme pack = atom.packages.disablePackage(packageName) - activatedPackages = atom.packages.getActivePackages() - expect(activatedPackages).not.toContain(pack) + + waitsFor -> + not (pack in atom.packages.getActivePackages()) + + runs -> expect(atom.config.get('core.themes')).not.toContain packageName expect(atom.config.get('core.themes')).not.toContain packageName expect(atom.config.get('core.disabledPackages')).not.toContain packageName diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index 95ef05760..d857a3b42 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -5,6 +5,10 @@ fs = require 'fs-plus' describe "Config", -> dotAtomPath = path.join(temp.dir, 'dot-atom-dir') + dotAtomPath = null + + beforeEach -> + dotAtomPath = temp.path('dot-atom-dir') describe ".get(keyPath)", -> it "allows a key path's value to be read", -> @@ -258,8 +262,10 @@ describe "Config", -> describe ".initializeConfigDirectory()", -> beforeEach -> + if fs.existsSync(dotAtomPath) + fs.removeSync(dotAtomPath) + atom.config.configDirPath = dotAtomPath - expect(fs.existsSync(atom.config.configDirPath)).toBeFalsy() afterEach -> fs.removeSync(dotAtomPath) diff --git a/spec/editor-component-spec.coffee b/spec/editor-component-spec.coffee index 23cd6a2d1..28f740531 100644 --- a/spec/editor-component-spec.coffee +++ b/spec/editor-component-spec.coffee @@ -4,9 +4,11 @@ nbsp = String.fromCharCode(160) describe "EditorComponent", -> [contentNode, editor, wrapperView, component, node, verticalScrollbarNode, horizontalScrollbarNode] = [] - [lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame] = [] + [lineHeightInPixels, charWidth, delayAnimationFrames, nextAnimationFrame, lineOverdrawMargin] = [] beforeEach -> + lineOverdrawMargin = 2 + waitsForPromise -> atom.packages.activatePackage('language-javascript') @@ -29,7 +31,7 @@ describe "EditorComponent", -> contentNode = document.querySelector('#jasmine-content') contentNode.style.width = '1000px' - wrapperView = new ReactEditorView(editor) + wrapperView = new ReactEditorView(editor, {lineOverdrawMargin}) wrapperView.attachToDom() {component} = wrapperView component.setLineHeight(1.3) @@ -41,57 +43,62 @@ describe "EditorComponent", -> verticalScrollbarNode = node.querySelector('.vertical-scrollbar') horizontalScrollbarNode = node.querySelector('.horizontal-scrollbar') + node.style.height = editor.getLineCount() * lineHeightInPixels + 'px' + node.style.width = '1000px' + component.measureHeightAndWidth() + afterEach -> contentNode.style.width = '' describe "line rendering", -> - it "renders only the currently-visible lines", -> + it "renders the currently-visible lines plus the overdraw margin", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.measureHeightAndWidth() - lines = node.querySelectorAll('.line') - expect(lines.length).toBe 6 - expect(lines[0].textContent).toBe editor.lineForScreenRow(0).text - expect(lines[5].textContent).toBe editor.lineForScreenRow(5).text + linesNode = node.querySelector('.lines') + expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + expect(node.querySelectorAll('.line').length).toBe 6 + 2 # no margin above + expect(component.lineNodeForScreenRow(0).textContent).toBe editor.lineForScreenRow(0).text + expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNodeForScreenRow(5).textContent).toBe editor.lineForScreenRow(5).text + expect(component.lineNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels - verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.scroll-view-content').style['-webkit-transform']).toBe "translate3d(0px, #{-2.5 * lineHeightInPixels}px, 0)" + expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, #{-4.5 * lineHeightInPixels}px, 0px)" + expect(node.querySelectorAll('.line').length).toBe 6 + 4 # margin above and below + expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNodeForScreenRow(2).textContent).toBe editor.lineForScreenRow(2).text + expect(component.lineNodeForScreenRow(9).offsetTop).toBe 9 * lineHeightInPixels + expect(component.lineNodeForScreenRow(9).textContent).toBe editor.lineForScreenRow(9).text - lineNodes = node.querySelectorAll('.line') - expect(lineNodes.length).toBe 6 - expect(lineNodes[0].offsetTop).toBe 2 * lineHeightInPixels - expect(lineNodes[0].textContent).toBe editor.lineForScreenRow(2).text - expect(lineNodes[5].textContent).toBe editor.lineForScreenRow(7).text - - it "updates absolute positions of subsequent lines when lines are inserted or removed", -> + it "updates the top position of subsequent lines when lines are inserted or removed", -> editor.getBuffer().deleteRows(0, 1) lineNodes = node.querySelectorAll('.line') - expect(lineNodes[0].offsetTop).toBe 0 - expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels - expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels editor.getBuffer().insert([0, 0], '\n\n') lineNodes = node.querySelectorAll('.line') - expect(lineNodes[0].offsetTop).toBe 0 - expect(lineNodes[1].offsetTop).toBe 1 * lineHeightInPixels - expect(lineNodes[2].offsetTop).toBe 2 * lineHeightInPixels - expect(lineNodes[3].offsetTop).toBe 3 * lineHeightInPixels - expect(lineNodes[4].offsetTop).toBe 4 * lineHeightInPixels + expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 * lineHeightInPixels + expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels + expect(component.lineNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels describe "when indent guides are enabled", -> beforeEach -> component.setShowIndentGuide(true) it "adds an 'indent-guide' class to spans comprising the leading whitespace", -> - lines = node.querySelectorAll('.line') - line1LeafNodes = getLeafNodes(lines[1]) + line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1)) expect(line1LeafNodes[0].textContent).toBe ' ' expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe true expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe false - line2LeafNodes = getLeafNodes(lines[2]) + line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) expect(line2LeafNodes[0].textContent).toBe ' ' expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true expect(line2LeafNodes[1].textContent).toBe ' ' @@ -101,8 +108,7 @@ describe "EditorComponent", -> it "renders leading whitespace spans with the 'indent-guide' class for empty lines", -> editor.getBuffer().insert([1, Infinity], '\n') - lines = node.querySelectorAll('.line') - line2LeafNodes = getLeafNodes(lines[2]) + line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) expect(line2LeafNodes.length).toBe 3 expect(line2LeafNodes[0].textContent).toBe ' ' @@ -114,8 +120,7 @@ describe "EditorComponent", -> it "renders indent guides correctly on lines containing only whitespace", -> editor.getBuffer().insert([1, Infinity], '\n ') - lines = node.querySelectorAll('.line') - line2LeafNodes = getLeafNodes(lines[2]) + line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2)) expect(line2LeafNodes.length).toBe 3 expect(line2LeafNodes[0].textContent).toBe ' ' expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe true @@ -125,9 +130,8 @@ describe "EditorComponent", -> expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe true it "does not render indent guides in trailing whitespace for lines containing non whitespace characters", -> - editor.getBuffer().setText (" hi ") - lines = node.querySelectorAll('.line') - line0LeafNodes = getLeafNodes(lines[0]) + editor.getBuffer().setText " hi " + line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0)) expect(line0LeafNodes[0].textContent).toBe ' ' expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe true expect(line0LeafNodes[1].textContent).toBe ' ' @@ -144,40 +148,39 @@ describe "EditorComponent", -> node.style.height = 4.5 * lineHeightInPixels + 'px' component.measureHeightAndWidth() - lines = node.querySelectorAll('.line-number') - expect(lines.length).toBe 6 - expect(lines[0].textContent).toBe "#{nbsp}1" - expect(lines[5].textContent).toBe "#{nbsp}6" + expect(node.querySelectorAll('.line-number').length).toBe 6 + 2 + 1 # line overdraw margin below + dummy line number + expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1" + expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}6" verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) - expect(node.querySelector('.line-numbers').style['-webkit-transform']).toBe "translate3d(0, #{-2.5 * lineHeightInPixels}px, 0)" + expect(node.querySelectorAll('.line-number').length).toBe 6 + 4 + 1 # line overdraw margin above/below + dummy line number - lineNumberNodes = node.querySelectorAll('.line-number') - expect(lineNumberNodes.length).toBe 6 - expect(lineNumberNodes[0].offsetTop).toBe 2 * lineHeightInPixels - expect(lineNumberNodes[5].offsetTop).toBe 7 * lineHeightInPixels - expect(lineNumberNodes[0].textContent).toBe "#{nbsp}3" - expect(lineNumberNodes[5].textContent).toBe "#{nbsp}8" + expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}3" + expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + return + expect(component.lineNumberNodeForScreenRow(7).textContent).toBe "#{nbsp}8" + expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe 7 * lineHeightInPixels - it "updates absolute positions of subsequent line numbers when lines are inserted or removed", -> + it "updates the translation of subsequent line numbers when lines are inserted or removed", -> editor.getBuffer().insert([0, 0], '\n\n') lineNumberNodes = node.querySelectorAll('.line-number') - expect(lineNumberNodes[0].offsetTop).toBe 0 - expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels - expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels - expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels - expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels editor.getBuffer().insert([0, 0], '\n\n') - lineNumberNodes = node.querySelectorAll('.line-number') - expect(lineNumberNodes[0].offsetTop).toBe 0 - expect(lineNumberNodes[1].offsetTop).toBe 1 * lineHeightInPixels - expect(lineNumberNodes[2].offsetTop).toBe 2 * lineHeightInPixels - expect(lineNumberNodes[3].offsetTop).toBe 3 * lineHeightInPixels - expect(lineNumberNodes[4].offsetTop).toBe 4 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe 6 * lineHeightInPixels it "renders • characters for soft-wrapped lines", -> editor.setSoftWrap(true) @@ -185,43 +188,50 @@ describe "EditorComponent", -> node.style.width = 30 * charWidth + 'px' component.measureHeightAndWidth() - lines = node.querySelectorAll('.line-number') - expect(lines.length).toBe 6 - expect(lines[0].textContent).toBe "#{nbsp}1" - expect(lines[1].textContent).toBe "#{nbsp}•" - expect(lines[2].textContent).toBe "#{nbsp}2" - expect(lines[3].textContent).toBe "#{nbsp}•" - expect(lines[4].textContent).toBe "#{nbsp}3" - expect(lines[5].textContent).toBe "#{nbsp}•" + expect(node.querySelectorAll('.line-number').length).toBe 6 + lineOverdrawMargin + 1 # 1 dummy line node + expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1" + expect(component.lineNumberNodeForScreenRow(1).textContent).toBe "#{nbsp}•" + expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}2" + expect(component.lineNumberNodeForScreenRow(3).textContent).toBe "#{nbsp}•" + expect(component.lineNumberNodeForScreenRow(4).textContent).toBe "#{nbsp}3" + expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}•" - it "pads line numbers to be right justified based on the maximum number of line number digits", -> + it "pads line numbers to be right-justified based on the maximum number of line number digits", -> editor.getBuffer().setText([1..10].join('\n')) - lineNumberNodes = toArray(node.querySelectorAll('.line-number')) + for screenRow in [0..8] + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{nbsp}#{screenRow + 1}" + expect(component.lineNumberNodeForScreenRow(9).textContent).toBe "10" - for node, i in lineNumberNodes[0..8] - expect(node.textContent).toBe "#{nbsp}#{i + 1}" - expect(lineNumberNodes[9].textContent).toBe '10' + gutterNode = node.querySelector('.gutter') + initialGutterWidth = gutterNode.offsetWidth # Removes padding when the max number of digits goes down editor.getBuffer().delete([[1, 0], [2, 0]]) - lineNumberNodes = toArray(node.querySelectorAll('.line-number')) - for node, i in lineNumberNodes - expect(node.textContent).toBe "#{i + 1}" + for screenRow in [0..8] + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{screenRow + 1}" + expect(gutterNode.offsetWidth).toBeLessThan initialGutterWidth + + # Increases padding when the max number of digits goes up + editor.getBuffer().insert([0, 0], '\n\n') + for screenRow in [0..8] + expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe "#{nbsp}#{screenRow + 1}" + expect(component.lineNumberNodeForScreenRow(9).textContent).toBe "10" + expect(gutterNode.offsetWidth).toBe initialGutterWidth describe "cursor rendering", -> - it "renders the currently visible cursors", -> + it "renders the currently visible cursors, translated relative to the scroll position", -> cursor1 = editor.getCursor() cursor1.setScreenPosition([0, 5]) node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * lineHeightInPixels + 'px' component.measureHeightAndWidth() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 expect(cursorNodes[0].offsetHeight).toBe lineHeightInPixels expect(cursorNodes[0].offsetWidth).toBe charWidth - expect(cursorNodes[0].offsetTop).toBe 0 - expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{5 * charWidth}px, #{0 * lineHeightInPixels}px, 0px)" cursor2 = editor.addCursorAtScreenPosition([6, 11]) cursor3 = editor.addCursorAtScreenPosition([4, 10]) @@ -229,25 +239,23 @@ describe "EditorComponent", -> cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 expect(cursorNodes[0].offsetTop).toBe 0 - expect(cursorNodes[0].offsetLeft).toBe 5 * charWidth - expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels - expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{5 * charWidth}px, #{0 * lineHeightInPixels}px, 0px)" + expect(cursorNodes[1].style['-webkit-transform']).toBe "translate3d(#{10 * charWidth}px, #{4 * lineHeightInPixels}px, 0px)" verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) + horizontalScrollbarNode.scrollLeft = 3.5 * charWidth + horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll')) cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 - expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels - expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth - expect(cursorNodes[1].offsetTop).toBe 4 * lineHeightInPixels - expect(cursorNodes[1].offsetLeft).toBe 10 * charWidth + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{(11 - 3.5) * charWidth}px, #{(6 - 2.5) * lineHeightInPixels}px, 0px)" + expect(cursorNodes[1].style['-webkit-transform']).toBe "translate3d(#{(10 - 3.5) * charWidth}px, #{(4 - 2.5) * lineHeightInPixels}px, 0px)" cursor3.destroy() cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 - expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels - expect(cursorNodes[0].offsetLeft).toBe 11 * charWidth + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{(11 - 3.5) * charWidth}px, #{(6 - 2.5) * lineHeightInPixels}px, 0px)" it "accounts for character widths when positioning cursors", -> atom.config.set('editor.fontFamily', 'sans-serif') @@ -256,7 +264,7 @@ describe "EditorComponent", -> cursor = node.querySelector('.cursor') cursorRect = cursor.getBoundingClientRect() - cursorLocationTextNode = node.querySelector('.storage.type.function.js').firstChild.firstChild + cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.storage.type.function.js').firstChild range = document.createRange() range.setStart(cursorLocationTextNode, 0) range.setEnd(cursorLocationTextNode, 1) @@ -266,44 +274,19 @@ describe "EditorComponent", -> expect(cursorRect.width).toBe rangeRect.width it "blinks cursors when they aren't moving", -> - editor.addCursorAtScreenPosition([1, 0]) - [cursorNode1, cursorNode2] = node.querySelectorAll('.cursor') - expect(cursorNode1.classList.contains('blink-off')).toBe false - expect(cursorNode2.classList.contains('blink-off')).toBe false + jasmine.unspy(window, 'setTimeout') - advanceClock(component.props.cursorBlinkPeriod / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe true - expect(cursorNode2.classList.contains('blink-off')).toBe true + cursorsNode = node.querySelector('.cursors') + expect(cursorsNode.classList.contains('blinking')).toBe true - advanceClock(component.props.cursorBlinkPeriod / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe false - expect(cursorNode2.classList.contains('blink-off')).toBe false - - advanceClock(component.props.cursorBlinkPeriod / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe true - expect(cursorNode2.classList.contains('blink-off')).toBe true - - # Stop blinking immediately when cursors move - advanceClock(component.props.cursorBlinkPeriod / 4) - expect(cursorNode1.classList.contains('blink-off')).toBe true - expect(cursorNode2.classList.contains('blink-off')).toBe true - - # Stop blinking for one full period after moving the cursor + # Stop blinking after moving the cursor editor.moveCursorRight() - expect(cursorNode1.classList.contains('blink-off')).toBe false - expect(cursorNode2.classList.contains('blink-off')).toBe false + expect(cursorsNode.classList.contains('blinking')).toBe false - advanceClock(component.props.cursorBlinkResumeDelay / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe false - expect(cursorNode2.classList.contains('blink-off')).toBe false - - advanceClock(component.props.cursorBlinkResumeDelay / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe true - expect(cursorNode2.classList.contains('blink-off')).toBe true - - advanceClock(component.props.cursorBlinkPeriod / 2) - expect(cursorNode1.classList.contains('blink-off')).toBe false - expect(cursorNode2.classList.contains('blink-off')).toBe false + # Resume blinking after resume delay passes + waits component.props.cursorBlinkResumeDelay + runs -> + expect(cursorsNode.classList.contains('blinking')).toBe true it "renders the hidden input field at the position of the last cursor if it is on screen", -> inputNode = node.querySelector('.hidden-input') @@ -330,13 +313,13 @@ describe "EditorComponent", -> cursorNodes = node.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 - expect(cursorNodes[0].offsetTop).toBe 6 * lineHeightInPixels - expect(cursorNodes[0].offsetLeft).toBe 8 * charWidth + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate3d(#{8 * charWidth}px, #{6 * lineHeightInPixels}px, 0px)" describe "selection rendering", -> - scrollViewClientLeft = null + [scrollViewNode, scrollViewClientLeft] = [] beforeEach -> + scrollViewNode = node.querySelector('.scroll-view') scrollViewClientLeft = node.querySelector('.scroll-view').getBoundingClientRect().left it "renders 1 region for 1-line selections", -> @@ -360,7 +343,7 @@ describe "EditorComponent", -> expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels @@ -377,13 +360,13 @@ describe "EditorComponent", -> expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(Math.ceil(region1Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels expect(region2Rect.height).toBe 3 * lineHeightInPixels expect(region2Rect.left).toBe scrollViewClientLeft + 0 - expect(Math.ceil(region2Rect.right)).toBe node.clientWidth # TODO: Remove ceiling when react-wrapper is removed + expect(region2Rect.right).toBe scrollViewNode.getBoundingClientRect().right region3Rect = regions[2].getBoundingClientRect() expect(region3Rect.top).toBe 5 * lineHeightInPixels @@ -391,9 +374,12 @@ describe "EditorComponent", -> expect(region3Rect.left).toBe scrollViewClientLeft + 0 expect(region3Rect.width).toBe 10 * charWidth - it "does not render empty selections", -> - expect(editor.getSelection().isEmpty()).toBe true - expect(node.querySelectorAll('.selection').length).toBe 0 + it "does not render empty selections unless they are the first selection (to prevent a Chromium rendering artifact caused by removing it)", -> + editor.addSelectionForBufferRange([[2, 2], [2, 2]]) + expect(editor.getSelection(0).isEmpty()).toBe true + expect(editor.getSelection(1).isEmpty()).toBe true + + expect(node.querySelectorAll('.selection').length).toBe 1 describe "mouse interactions", -> linesNode = null @@ -527,16 +513,16 @@ describe "EditorComponent", -> editor.setScrollTop(10) expect(verticalScrollbarNode.scrollTop).toBe 10 - it "updates the horizontal scrollbar and scroll view content x transform based on the scrollLeft of the model", -> + it "updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model", -> node.style.width = 30 * charWidth + 'px' component.measureHeightAndWidth() - scrollViewContentNode = node.querySelector('.scroll-view-content') - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0)" + linesNode = node.querySelector('.lines') + expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" expect(horizontalScrollbarNode.scrollLeft).toBe 0 editor.setScrollLeft(100) - expect(scrollViewContentNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0)" + expect(linesNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0px)" expect(horizontalScrollbarNode.scrollLeft).toBe 100 it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> @@ -554,7 +540,7 @@ describe "EditorComponent", -> node.style.width = 10 * charWidth + 'px' component.measureHeightAndWidth() editor.setScrollBottom(editor.getScrollHeight()) - lastLineNode = last(node.querySelectorAll('.line')) + lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow()) bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top expect(bottomOfLastLine).toBe topOfHorizontalScrollbar @@ -562,7 +548,6 @@ describe "EditorComponent", -> # Scroll so there's no space below the last line when the horizontal scrollbar disappears node.style.width = 100 * charWidth + 'px' component.measureHeightAndWidth() - lastLineNode = last(node.querySelectorAll('.line')) bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom bottomOfEditor = node.getBoundingClientRect().bottom expect(bottomOfLastLine).toBe bottomOfEditor @@ -574,11 +559,9 @@ describe "EditorComponent", -> editor.setScrollLeft(Infinity) - lineNodes = node.querySelectorAll('.line') - rightOfLongestLine = lineNodes[6].getBoundingClientRect().right + rightOfLongestLine = component.lineNodeForScreenRow(6).getBoundingClientRect().right leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left - - expect(rightOfLongestLine).toBe leftOfVerticalScrollbar - 1 # Leave 1 px so the cursor is visible on the end of the line + expect(Math.round(rightOfLongestLine)).toBe leftOfVerticalScrollbar - 1 # Leave 1 px so the cursor is visible on the end of the line it "only displays dummy scrollbars when scrollable in that direction", -> expect(verticalScrollbarNode.style.display).toBe 'none' @@ -670,6 +653,32 @@ describe "EditorComponent", -> expect(verticalScrollbarNode.scrollTop).toBe 10 expect(horizontalScrollbarNode.scrollLeft).toBe 15 + describe "when the mousewheel event's target is a line", -> + it "keeps the line on the DOM if it is scrolled off-screen", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * charWidth + 'px' + component.measureHeightAndWidth() + + lineNode = node.querySelector('.line') + wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500) + Object.defineProperty(wheelEvent, 'target', get: -> lineNode) + node.dispatchEvent(wheelEvent) + + expect(node.contains(lineNode)).toBe true + + describe "when the mousewheel event's target is a line number", -> + it "keeps the line number on the DOM if it is scrolled off-screen", -> + node.style.height = 4.5 * lineHeightInPixels + 'px' + node.style.width = 20 * charWidth + 'px' + component.measureHeightAndWidth() + + lineNumberNode = node.querySelectorAll('.line-number')[1] + wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500) + Object.defineProperty(wheelEvent, 'target', get: -> lineNumberNode) + node.dispatchEvent(wheelEvent) + + expect(node.contains(lineNumberNode)).toBe true + describe "input events", -> inputNode = null diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index 98638034f..6b37d6f86 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -1731,26 +1731,26 @@ describe "Editor", -> editor.backspace() expect(editor.lineForBufferRow(0)).toBe 'var = () {' - describe ".backspaceToBeginningOfWord()", -> + describe ".deleteToBeginningOfWord()", -> describe "when no text is selected", -> it "deletes all text between the cursor and the beginning of the word", -> editor.setCursorBufferPosition([1, 24]) editor.addCursorAtBufferPosition([3, 5]) [cursor1, cursor2] = editor.getCursors() - editor.backspaceToBeginningOfWord() + editor.deleteToBeginningOfWord() expect(buffer.lineForRow(1)).toBe ' var sort = function(ems) {' expect(buffer.lineForRow(3)).toBe ' ar pivot = items.shift(), current, left = [], right = [];' expect(cursor1.getBufferPosition()).toEqual [1, 22] expect(cursor2.getBufferPosition()).toEqual [3, 4] - editor.backspaceToBeginningOfWord() + editor.deleteToBeginningOfWord() expect(buffer.lineForRow(1)).toBe ' var sort = functionems) {' expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return itemsar pivot = items.shift(), current, left = [], right = [];' expect(cursor1.getBufferPosition()).toEqual [1, 21] expect(cursor2.getBufferPosition()).toEqual [2, 39] - editor.backspaceToBeginningOfWord() + editor.deleteToBeginningOfWord() expect(buffer.lineForRow(1)).toBe ' var sort = ems) {' expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return ar pivot = items.shift(), current, left = [], right = [];' expect(cursor1.getBufferPosition()).toEqual [1, 13] @@ -1758,24 +1758,24 @@ describe "Editor", -> editor.setText(' var sort') editor.setCursorBufferPosition([0, 2]) - editor.backspaceToBeginningOfWord() + editor.deleteToBeginningOfWord() expect(buffer.lineForRow(0)).toBe 'var sort' describe "when text is selected", -> it "deletes only selected text", -> editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.backspaceToBeginningOfWord() + editor.deleteToBeginningOfWord() expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - describe ".backspaceToBeginningOfLine()", -> + describe ".deleteToBeginningOfLine()", -> describe "when no text is selected", -> it "deletes all text between the cursor and the beginning of the line", -> editor.setCursorBufferPosition([1, 24]) editor.addCursorAtBufferPosition([2, 5]) [cursor1, cursor2] = editor.getCursors() - editor.backspaceToBeginningOfLine() + editor.deleteToBeginningOfLine() expect(buffer.lineForRow(1)).toBe 'ems) {' expect(buffer.lineForRow(2)).toBe 'f (items.length <= 1) return items;' expect(cursor1.getBufferPosition()).toEqual [1, 0] @@ -1784,13 +1784,13 @@ describe "Editor", -> describe "when at the beginning of the line", -> it "deletes the newline", -> editor.setCursorBufferPosition([2]) - editor.backspaceToBeginningOfLine() + editor.deleteToBeginningOfLine() expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' describe "when text is selected", -> it "still deletes all text to begginning of the line", -> editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.backspaceToBeginningOfLine() + editor.deleteToBeginningOfLine() expect(buffer.lineForRow(1)).toBe 'ems) {' expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' diff --git a/spec/editor-view-spec.coffee b/spec/editor-view-spec.coffee index d01ed45e5..fac5bc28a 100644 --- a/spec/editor-view-spec.coffee +++ b/spec/editor-view-spec.coffee @@ -1803,6 +1803,13 @@ describe "EditorView", -> expect(editorView.renderedLines.find('.line:eq(10) .indent-guide').text()).toBe "#{eol} " expect(editorView.renderedLines.find('.line:eq(10) .invisible-character').text()).toBe eol + describe "when editor.showIndentGuide is set to false", -> + it "does not render the indent guide on whitespace only lines (regression)", -> + editorView.attachToDom() + editor.setText(' ') + atom.config.set('editor.showIndentGuide', false) + expect(editorView.renderedLines.find('.line:eq(0) .indent-guide').length).toBe 0 + describe "when soft-wrap is enabled", -> beforeEach -> jasmine.unspy(window, 'setTimeout') diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee index 831b34ee6..dbb1ce672 100644 --- a/spec/theme-manager-spec.coffee +++ b/spec/theme-manager-spec.coffee @@ -286,5 +286,10 @@ describe "ThemeManager", -> runs -> spyOn(console, 'warn') expect(-> atom.config.set('core.themes', ['atom-light-ui', 'theme-really-does-not-exist'])).not.toThrow() + + waitsFor (done) -> + themeManager.once 'reloaded', done + + runs -> expect(console.warn.callCount).toBe 1 expect(console.warn.argsForCall[0][0].length).toBeGreaterThan 0 diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee index c310c9c48..908e9fc8a 100644 --- a/src/context-menu-manager.coffee +++ b/src/context-menu-manager.coffee @@ -98,5 +98,6 @@ class ContextMenuManager showForEvent: (event) -> @activeElement = event.target menuTemplate = @combinedMenuTemplateForElement(event.target) + return unless menuTemplate?.length > 0 @executeBuildHandlers(event, menuTemplate) remote.getCurrentWindow().emit('context-menu', menuTemplate) diff --git a/src/cursor-component.coffee b/src/cursor-component.coffee index fcc6c2022..36ba33a08 100644 --- a/src/cursor-component.coffee +++ b/src/cursor-component.coffee @@ -6,8 +6,10 @@ CursorComponent = React.createClass displayName: 'CursorComponent' render: -> - {top, left, height, width} = @props.cursor.getPixelRect() - className = 'cursor' - className += ' blink-off' if @props.blinkOff + {cursor, scrollTop, scrollLeft} = @props + {top, left, height, width} = cursor.getPixelRect() + top -= scrollTop + left -= scrollLeft + WebkitTransform = "translate3d(#{left}px, #{top}px, 0px)" - div className: className, style: {top, left, height, width} + div className: 'cursor', style: {height, width, WebkitTransform} diff --git a/src/cursor-view.coffee b/src/cursor-view.coffee index 857e5e137..a3d67264b 100644 --- a/src/cursor-view.coffee +++ b/src/cursor-view.coffee @@ -78,7 +78,11 @@ class CursorView extends View setVisible: (visible) -> unless @visible is visible @visible = visible - @toggle(@visible) + hiddenCursor = 'hidden-cursor' + if visible + @removeClass hiddenCursor + else + @addClass hiddenCursor stopBlinking: -> @constructor.stopBlinking(this) if @blinking diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index e3143f1f1..16213b1b9 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -1,10 +1,9 @@ React = require 'react' {div} = require 'reactionary' -{debounce} = require 'underscore-plus' +{debounce, toArray} = require 'underscore-plus' SubscriberMixin = require './subscriber-mixin' CursorComponent = require './cursor-component' - module.exports = CursorsComponent = React.createClass displayName: 'CursorsComponent' @@ -13,22 +12,24 @@ CursorsComponent = React.createClass cursorBlinkIntervalHandle: null render: -> - {editor} = @props - blinkOff = @state.blinkCursorsOff + {editor, scrollTop, scrollLeft} = @props + {blinking} = @state - div className: 'cursors', + className = 'cursors' + className += ' blinking' if blinking + + div {className}, if @isMounted() for selection in editor.getSelections() if selection.isEmpty() and editor.selectionIntersectsVisibleRowRange(selection) {cursor} = selection - CursorComponent({key: cursor.id, cursor, blinkOff}) + CursorComponent({key: cursor.id, cursor, scrollTop, scrollLeft}) getInitialState: -> - blinkCursorsOff: false + blinking: true componentDidMount: -> {editor} = @props - @startBlinkingCursors() componentWillUnmount: -> clearInterval(@cursorBlinkIntervalHandle) @@ -36,15 +37,21 @@ CursorsComponent = React.createClass componentWillUpdate: ({cursorsMoved}) -> @pauseCursorBlinking() if cursorsMoved + componentDidUpdate: -> + @syncCursorAnimations() if @props.selectionAdded + startBlinkingCursors: -> - @cursorBlinkIntervalHandle = setInterval(@toggleCursorBlink, @props.cursorBlinkPeriod / 2) + @setState(blinking: true) if @isMounted() startBlinkingCursorsAfterDelay: null # Created lazily - toggleCursorBlink: -> @setState(blinkCursorsOff: not @state.blinkCursorsOff) - pauseCursorBlinking: -> - @state.blinkCursorsOff = false - clearInterval(@cursorBlinkIntervalHandle) + @state.blinking = false @startBlinkingCursorsAfterDelay ?= debounce(@startBlinkingCursors, @props.cursorBlinkResumeDelay) @startBlinkingCursorsAfterDelay() + + syncCursorAnimations: -> + node = @getDOMNode() + cursorNodes = toArray(node.children) + node.removeChild(cursorNode) for cursorNode in cursorNodes + node.appendChild(cursorNode) for cursorNode in cursorNodes diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index 98225df6e..5c6a05d1e 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -241,7 +241,8 @@ class DisplayBuffer extends Model heightInLines = Math.ceil(@getHeight() / @getLineHeight()) + 1 startRow = Math.floor(@getScrollTop() / @getLineHeight()) - endRow = Math.min(@getLineCount(), Math.ceil(startRow + heightInLines)) + endRow = Math.min(@getLineCount(), startRow + heightInLines) + [startRow, endRow] intersectsVisibleRowRange: (startRow, endRow) -> diff --git a/src/editor-component.coffee b/src/editor-component.coffee index a4a2eadef..9a30ab6ca 100644 --- a/src/editor-component.coffee +++ b/src/editor-component.coffee @@ -20,15 +20,19 @@ EditorComponent = React.createClass batchingUpdates: false updateRequested: false cursorsMoved: false - preservedRowRange: null + selectionChanged: false + selectionAdded: false scrollingVertically: false gutterWidth: 0 refreshingScrollbars: false measuringScrollbars: true + pendingVerticalScrollDelta: 0 + pendingHorizontalScrollDelta: 0 + mouseWheelScreenRow: null render: -> {focused, fontSize, lineHeight, fontFamily, showIndentGuide} = @state - {editor, cursorBlinkPeriod, cursorBlinkResumeDelay} = @props + {editor, cursorBlinkResumeDelay} = @props maxLineNumberDigits = editor.getScreenLineCount().toString().length if @isMounted() @@ -48,16 +52,17 @@ EditorComponent = React.createClass div className: className, style: {fontSize, lineHeight, fontFamily}, tabIndex: -1, GutterComponent { - editor, renderedRowRange, maxLineNumberDigits, scrollTop, scrollHeight, - lineHeight: lineHeightInPixels, fontSize, fontFamily, @pendingChanges, - onWidthChanged: @onGutterWidthChanged + ref: 'gutter', editor, renderedRowRange, maxLineNumberDigits, + scrollTop, scrollHeight, lineHeight: lineHeightInPixels, fontSize, fontFamily, + @pendingChanges, onWidthChanged: @onGutterWidthChanged, @mouseWheelScreenRow } EditorScrollViewComponent { - ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide - scrollHeight, scrollWidth, lineHeight: lineHeightInPixels, - renderedRowRange, @pendingChanges, @scrollingVertically, @cursorsMoved, - cursorBlinkPeriod, cursorBlinkResumeDelay, @onInputFocused, @onInputBlurred + ref: 'scrollView', editor, fontSize, fontFamily, showIndentGuide, + lineHeight: lineHeightInPixels, renderedRowRange, @pendingChanges, + scrollTop, scrollLeft, scrollHeight, scrollWidth, @scrollingVertically, + @cursorsMoved, @selectionChanged, @selectionAdded, cursorBlinkResumeDelay, + @onInputFocused, @onInputBlurred, @mouseWheelScreenRow } ScrollbarComponent @@ -93,17 +98,17 @@ EditorComponent = React.createClass width: verticalScrollbarWidth getRenderedRowRange: -> - renderedRowRange = @props.editor.getVisibleRowRange() - if @preservedRowRange? - renderedRowRange[0] = Math.min(@preservedRowRange[0], renderedRowRange[0]) - renderedRowRange[1] = Math.max(@preservedRowRange[1], renderedRowRange[1]) - renderedRowRange + {editor, lineOverdrawMargin} = @props + [visibleStartRow, visibleEndRow] = editor.getVisibleRowRange() + renderedStartRow = Math.max(0, visibleStartRow - lineOverdrawMargin) + renderedEndRow = Math.min(editor.getLineCount(), visibleEndRow + lineOverdrawMargin) + [renderedStartRow, renderedEndRow] getInitialState: -> {} getDefaultProps: -> - cursorBlinkPeriod: 800 - cursorBlinkResumeDelay: 200 + cursorBlinkResumeDelay: 100 + lineOverdrawMargin: 8 componentWillMount: -> @pendingChanges = [] @@ -130,6 +135,8 @@ EditorComponent = React.createClass componentDidUpdate: -> @pendingChanges.length = 0 @cursorsMoved = false + @selectionChanged = false + @selectionAdded = false @refreshingScrollbars = false @measureScrollbars() if @measuringScrollbars @props.parentView.trigger 'editor:display-updated' @@ -140,9 +147,8 @@ EditorComponent = React.createClass @subscribe editor, 'batched-updates-ended', @onBatchedUpdatesEnded @subscribe editor, 'screen-lines-changed', @onScreenLinesChanged @subscribe editor, 'cursors-moved', @onCursorsMoved - @subscribe editor, 'selection-screen-range-changed', @requestUpdate + @subscribe editor, 'selection-removed selection-screen-range-changed', @onSelectionChanged @subscribe editor, 'selection-added', @onSelectionAdded - @subscribe editor, 'selection-removed', @onSelectionAdded @subscribe editor.$scrollTop.changes, @onScrollTopChanged @subscribe editor.$scrollLeft.changes, @requestUpdate @subscribe editor.$height.changes, @requestUpdate @@ -174,8 +180,8 @@ EditorComponent = React.createClass 'editor:move-to-previous-word': => editor.moveCursorToPreviousWord() 'editor:select-word': => editor.selectWord() 'editor:consolidate-selections': @consolidateSelections - 'editor:backspace-to-beginning-of-word': => editor.backspaceToBeginningOfWord() - 'editor:backspace-to-beginning-of-line': => editor.backspaceToBeginningOfLine() + 'editor:delete-to-beginning-of-word': => editor.deleteToBeginningOfWord() + 'editor:delete-to-beginning-of-line': => editor.deleteToBeginningOfLine() 'editor:delete-to-end-of-word': => editor.deleteToEndOfWord() 'editor:delete-line': => editor.deleteLine() 'editor:cut-to-end-of-line': => editor.cutToEndOfLine() @@ -319,14 +325,32 @@ EditorComponent = React.createClass @pendingScrollLeft = null onMouseWheel: (event) -> + event.preventDefault() + screenRow = @screenRowForNode(event.target) + @mouseWheelScreenRow = screenRow if screenRow? + animationFramePending = @pendingHorizontalScrollDelta isnt 0 or @pendingVerticalScrollDelta isnt 0 + # Only scroll in one direction at a time {wheelDeltaX, wheelDeltaY} = event if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) - @refs.horizontalScrollbar.getDOMNode().scrollLeft -= wheelDeltaX + @pendingHorizontalScrollDelta -= wheelDeltaX else - @refs.verticalScrollbar.getDOMNode().scrollTop -= wheelDeltaY + @pendingVerticalScrollDelta -= wheelDeltaY - event.preventDefault() + unless animationFramePending + requestAnimationFrame => + {editor} = @props + editor.setScrollTop(editor.getScrollTop() + @pendingVerticalScrollDelta) + editor.setScrollLeft(editor.getScrollLeft() + @pendingHorizontalScrollDelta) + @pendingVerticalScrollDelta = 0 + @pendingHorizontalScrollDelta = 0 + + screenRowForNode: (node) -> + while node isnt document + if screenRow = node.dataset.screenRow + return parseInt(screenRow) + node = node.parentNode + null onStylesheetsChanged: (stylesheet) -> @refreshScrollbars() if @containsScrollbarSelector(stylesheet) @@ -357,13 +381,6 @@ EditorComponent = React.createClass # if the editor's content and dimensions require them to be visible. @requestUpdate() - clearPreservedRowRange: -> - @preservedRowRange = null - @scrollingVertically = false - @requestUpdate() - - clearPreservedRowRangeAfterDelay: null # Created lazily - onBatchedUpdatesStarted: -> @batchingUpdates = true @@ -379,20 +396,31 @@ EditorComponent = React.createClass @pendingChanges.push(change) @requestUpdate() if editor.intersectsVisibleRowRange(change.start, change.end + 1) # TODO: Use closed-open intervals for change events + onSelectionChanged: (selection) -> + {editor} = @props + if editor.selectionIntersectsVisibleRowRange(selection) + @selectionChanged = true + @requestUpdate() + onSelectionAdded: (selection) -> {editor} = @props - @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + if editor.selectionIntersectsVisibleRowRange(selection) + @selectionChanged = true + @selectionAdded = true + @requestUpdate() onScrollTopChanged: -> - @preservedRowRange = @getRenderedRowRange() @scrollingVertically = true - @clearPreservedRowRangeAfterDelay ?= debounce(@clearPreservedRowRange, 200) - @clearPreservedRowRangeAfterDelay() + @requestUpdate() + @stopScrollingAfterDelay ?= debounce(@onStoppedScrolling, 100) + @stopScrollingAfterDelay() + + onStoppedScrolling: -> + @scrollingVertically = false + @mouseWheelScreenRow = null @requestUpdate() - onSelectionRemoved: (selection) -> - {editor} = @props - @requestUpdate() if editor.selectionIntersectsVisibleRowRange(selection) + stopScrollingAfterDelay: null # created lazily onCursorsMoved: -> @cursorsMoved = true @@ -411,3 +439,7 @@ EditorComponent = React.createClass consolidateSelections: (e) -> e.abortKeyBinding() unless @props.editor.consolidateSelections() + + lineNodeForScreenRow: (screenRow) -> @refs.scrollView.lineNodeForScreenRow(screenRow) + + lineNumberNodeForScreenRow: (screenRow) -> @refs.gutter.lineNumberNodeForScreenRow(screenRow) diff --git a/src/editor-scroll-view-component.coffee b/src/editor-scroll-view-component.coffee index bc04b41f3..1363c5e67 100644 --- a/src/editor-scroll-view-component.coffee +++ b/src/editor-scroll-view-component.coffee @@ -17,19 +17,14 @@ EditorScrollViewComponent = React.createClass render: -> {editor, fontSize, fontFamily, lineHeight, showIndentGuide} = @props - {scrollHeight, scrollWidth, renderedRowRange, pendingChanges, scrollingVertically} = @props - {cursorBlinkPeriod, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props + {renderedRowRange, pendingChanges, scrollTop, scrollLeft, scrollHeight, scrollWidth, scrollingVertically, mouseWheelScreenRow} = @props + {selectionChanged, selectionAdded, cursorBlinkResumeDelay, cursorsMoved, onInputFocused, onInputBlurred} = @props if @isMounted() inputStyle = @getHiddenInputPosition() inputStyle.WebkitTransform = 'translateZ(0)' - contentStyle = - height: scrollHeight - minWidth: scrollWidth - WebkitTransform: "translate3d(#{-editor.getScrollLeft()}px, #{-editor.getScrollTop()}px, 0)" - - div className: 'scroll-view', + div className: 'scroll-view', onMouseDown: @onMouseDown, InputComponent ref: 'input' className: 'hidden-input' @@ -38,14 +33,12 @@ EditorScrollViewComponent = React.createClass onFocus: onInputFocused onBlur: onInputBlurred - div className: 'scroll-view-content', style: contentStyle, onMouseDown: @onMouseDown, - CursorsComponent({editor, cursorsMoved, cursorBlinkPeriod, cursorBlinkResumeDelay}) - LinesComponent { - ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, - renderedRowRange, pendingChanges, scrollingVertically - } - div className: 'underlayer', - SelectionsComponent({editor}) + CursorsComponent({editor, scrollTop, scrollLeft, cursorsMoved, selectionAdded, cursorBlinkResumeDelay}) + LinesComponent { + ref: 'lines', editor, fontSize, fontFamily, lineHeight, showIndentGuide, + renderedRowRange, pendingChanges, scrollTop, scrollLeft, scrollingVertically, + selectionChanged, scrollHeight, scrollWidth, mouseWheelScreenRow + } componentDidMount: -> @getDOMNode().addEventListener 'overflowchanged', @onOverflowChanged @@ -198,3 +191,5 @@ EditorScrollViewComponent = React.createClass focus: -> @refs.input.focus() + + lineNodeForScreenRow: (screenRow) -> @refs.lines.lineNodeForScreenRow(screenRow) diff --git a/src/editor-view.coffee b/src/editor-view.coffee index 3aa988d06..2b69becd1 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -154,8 +154,8 @@ class EditorView extends View 'editor:move-to-previous-word': => @editor.moveCursorToPreviousWord() 'editor:select-word': => @editor.selectWord() 'editor:consolidate-selections': (event) => @consolidateSelections(event) - 'editor:backspace-to-beginning-of-word': => @editor.backspaceToBeginningOfWord() - 'editor:backspace-to-beginning-of-line': => @editor.backspaceToBeginningOfLine() + 'editor:delete-to-beginning-of-word': => @editor.deleteToBeginningOfWord() + 'editor:delete-to-beginning-of-line': => @editor.deleteToBeginningOfLine() 'editor:delete-to-end-of-word': => @editor.deleteToEndOfWord() 'editor:delete-line': => @editor.deleteLine() 'editor:cut-to-end-of-line': => @editor.cutToEndOfLine() @@ -1490,7 +1490,7 @@ class EditorView extends View position = 0 for token in tokens @updateScopeStack(line, scopeStack, token.scopes) - hasIndentGuide = not mini and showIndentGuide and token.hasLeadingWhitespace or (token.hasTrailingWhitespace and lineIsWhitespaceOnly) + hasIndentGuide = not mini and showIndentGuide and (token.hasLeadingWhitespace or (token.hasTrailingWhitespace and lineIsWhitespaceOnly)) line.push(token.getValueAsHtml({invisibles, hasIndentGuide})) position += token.value.length diff --git a/src/editor.coffee b/src/editor.coffee index 467f13e32..bd714ecb1 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -109,8 +109,8 @@ TextMateScopeSelector = require('first-mate').ScopeSelector # - {::insertNewlineAbove} # - {::insertNewlineBelow} # - {::backspace} -# - {::backspaceToBeginningOfWord} -# - {::backspaceToBeginningOfLine} +# - {::deleteToBeginningOfWord} +# - {::deleteToBeginningOfLine} # - {::delete} # - {::deleteToEndOfWord} # - {::deleteLine} @@ -658,17 +658,27 @@ class Editor extends Model backspace: -> @mutateSelectedText (selection) -> selection.backspace() + # Deprecated: Use {::deleteToBeginningOfWord} instead. + backspaceToBeginningOfWord: -> + deprecate("Use Editor::deleteToBeginningOfWord() instead") + @deleteToBeginningOfWord() + + # Deprecated: Use {::deleteToBeginningOfLine} instead. + backspaceToBeginningOfLine: -> + deprecate("Use Editor::deleteToBeginningOfLine() instead") + @deleteToBeginningOfLine() + # Public: For each selection, if the selection is empty, delete all characters # of the containing word that precede the cursor. Otherwise delete the # selected text. - backspaceToBeginningOfWord: -> - @mutateSelectedText (selection) -> selection.backspaceToBeginningOfWord() + deleteToBeginningOfWord: -> + @mutateSelectedText (selection) -> selection.deleteToBeginningOfWord() # Public: For each selection, if the selection is empty, delete all characters # of the containing line that precede the cursor. Otherwise delete the # selected text. - backspaceToBeginningOfLine: -> - @mutateSelectedText (selection) -> selection.backspaceToBeginningOfLine() + deleteToBeginningOfLine: -> + @mutateSelectedText (selection) -> selection.deleteToBeginningOfLine() # Public: For each selection, if the selection is empty, delete the character # preceding the cursor. Otherwise delete the selected text. @@ -1259,6 +1269,9 @@ class Editor extends Model # Returns: An {Array} of {Selection}s. getSelections: -> new Array(@selections...) + selectionsForScreenRows: (startRow, endRow) -> + @getSelections().filter (selection) -> selection.intersectsScreenRowRange(startRow, endRow) + # Public: Get the most recent {Selection} or the selection at the given # index. # diff --git a/src/gutter-component.coffee b/src/gutter-component.coffee index 16903d5fd..1454494e1 100644 --- a/src/gutter-component.coffee +++ b/src/gutter-component.coffee @@ -1,46 +1,33 @@ React = require 'react' {div} = require 'reactionary' -{isEqual, isEqualForProperties, multiplyString} = require 'underscore-plus' +{isEqual, isEqualForProperties, multiplyString, toArray} = require 'underscore-plus' SubscriberMixin = require './subscriber-mixin' +WrapperDiv = document.createElement('div') + module.exports = GutterComponent = React.createClass displayName: 'GutterComponent' mixins: [SubscriberMixin] lastMeasuredWidth: null + dummyLineNumberNode: null render: -> + {scrollHeight, scrollTop} = @props + div className: 'gutter', - @renderLineNumbers() if @isMounted() + div className: 'line-numbers', ref: 'lineNumbers', style: + height: scrollHeight + WebkitTransform: "translate3d(0px, #{-scrollTop}px, 0px)" - renderLineNumbers: -> - {editor, renderedRowRange, maxLineNumberDigits, scrollTop, scrollHeight} = @props - [startRow, endRow] = renderedRowRange - charWidth = editor.getDefaultCharWidth() - lineHeight = editor.getLineHeight() - style = - width: charWidth * (maxLineNumberDigits + 1.5) - height: scrollHeight - WebkitTransform: "translate3d(0, #{-scrollTop}px, 0)" + componentWillMount: -> + @lineNumberNodesById = {} + @lineNumberIdsByScreenRow = {} + @screenRowsByLineNumberId = {} - lineNumbers = [] - tokenizedLines = editor.linesForScreenRows(startRow, endRow - 1) - tokenizedLines.push({id: 0}) if tokenizedLines.length is 0 - for bufferRow, i in editor.bufferRowsForScreenRows(startRow, endRow - 1) - if bufferRow is lastBufferRow - lineNumber = '•' - else - lastBufferRow = bufferRow - lineNumber = (bufferRow + 1).toString() - - key = tokenizedLines[i].id - screenRow = startRow + i - lineNumbers.push(LineNumberComponent({key, lineNumber, maxLineNumberDigits, bufferRow, screenRow, lineHeight})) - lastBufferRow = bufferRow - - div className: 'line-numbers', style: style, - lineNumbers + componentDidMount: -> + @appendDummyLineNumber() # Only update the gutter if the visible row range has changed or if a # non-zero-delta change to the screen lines has occurred within the current @@ -49,39 +36,131 @@ GutterComponent = React.createClass return true unless isEqualForProperties(newProps, @props, 'renderedRowRange', 'scrollTop', 'lineHeight', 'fontSize') {renderedRowRange, pendingChanges} = newProps - for change in pendingChanges when change.screenDelta > 0 or change.bufferDelta > 0 + for change in pendingChanges when Math.abs(change.screenDelta) > 0 or Math.abs(change.bufferDelta) > 0 return true unless change.end <= renderedRowRange.start or renderedRowRange.end <= change.start false componentDidUpdate: (oldProps) -> - unless @lastMeasuredWidth? and isEqualForProperties(oldProps, @props, 'maxLineNumberDigits', 'fontSize', 'fontFamily') - width = @getDOMNode().offsetWidth - if width isnt @lastMeasuredWidth - @lastMeasuredWidth = width - @props.onWidthChanged(width) + unless oldProps.maxLineNumberDigits is @props.maxLineNumberDigits + @updateDummyLineNumber() + @removeLineNumberNodes() -LineNumberComponent = React.createClass - displayName: 'LineNumberComponent' + @measureWidth() unless @lastMeasuredWidth? and isEqualForProperties(oldProps, @props, 'maxLineNumberDigits', 'fontSize', 'fontFamily') + @clearScreenRowCaches() unless oldProps.lineHeight is @props.lineHeight + @updateLineNumbers() - render: -> - {bufferRow, screenRow, lineHeight} = @props - div - className: "line-number line-number-#{bufferRow}" - style: {top: screenRow * lineHeight} - 'data-buffer-row': bufferRow - 'data-screen-row': screenRow - dangerouslySetInnerHTML: {__html: @buildInnerHTML()} + clearScreenRowCaches: -> + @lineNumberIdsByScreenRow = {} + @screenRowsByLineNumberId = {} - buildInnerHTML: -> - {lineNumber, maxLineNumberDigits} = @props - if lineNumber.length < maxLineNumberDigits - padding = multiplyString(' ', maxLineNumberDigits - lineNumber.length) - padding + lineNumber + @iconDivHTML + # This dummy line number element holds the gutter to the appropriate width, + # since the real line numbers are absolutely positioned for performance reasons. + appendDummyLineNumber: -> + {maxLineNumberDigits} = @props + WrapperDiv.innerHTML = @buildLineNumberHTML(0, false, maxLineNumberDigits) + @dummyLineNumberNode = WrapperDiv.children[0] + @refs.lineNumbers.getDOMNode().appendChild(@dummyLineNumberNode) + + updateDummyLineNumber: -> + @dummyLineNumberNode.innerHTML = @buildLineNumberInnerHTML(0, false, @props.maxLineNumberDigits) + + updateLineNumbers: -> + lineNumberIdsToPreserve = @appendOrUpdateVisibleLineNumberNodes() + @removeLineNumberNodes(lineNumberIdsToPreserve) + + appendOrUpdateVisibleLineNumberNodes: -> + {editor, renderedRowRange, scrollTop, maxLineNumberDigits} = @props + [startRow, endRow] = renderedRowRange + + newLineNumberIds = null + newLineNumbersHTML = null + visibleLineNumberIds = new Set + + wrapCount = 0 + for bufferRow, index in editor.bufferRowsForScreenRows(startRow, endRow - 1) + screenRow = startRow + index + + if bufferRow is lastBufferRow + id = "#{bufferRow}-#{wrapCount++}" + else + id = bufferRow.toString() + lastBufferRow = bufferRow + wrapCount = 0 + + visibleLineNumberIds.add(id) + + if @hasLineNumberNode(id) + @updateLineNumberNode(id, screenRow) + else + newLineNumberIds ?= [] + newLineNumbersHTML ?= "" + newLineNumberIds.push(id) + newLineNumbersHTML += @buildLineNumberHTML(bufferRow, wrapCount > 0, maxLineNumberDigits, screenRow) + @screenRowsByLineNumberId[id] = screenRow + @lineNumberIdsByScreenRow[screenRow] = id + + if newLineNumberIds? + WrapperDiv.innerHTML = newLineNumbersHTML + newLineNumberNodes = toArray(WrapperDiv.children) + + node = @refs.lineNumbers.getDOMNode() + for lineNumberId, i in newLineNumberIds + lineNumberNode = newLineNumberNodes[i] + @lineNumberNodesById[lineNumberId] = lineNumberNode + node.appendChild(lineNumberNode) + + visibleLineNumberIds + + removeLineNumberNodes: (lineNumberIdsToPreserve) -> + {mouseWheelScreenRow} = @props + node = @refs.lineNumbers.getDOMNode() + for lineNumberId, lineNumberNode of @lineNumberNodesById when not lineNumberIdsToPreserve?.has(lineNumberId) + screenRow = @screenRowsByLineNumberId[lineNumberId] + unless screenRow is mouseWheelScreenRow + delete @lineNumberNodesById[lineNumberId] + delete @lineNumberIdsByScreenRow[screenRow] if @lineNumberIdsByScreenRow[screenRow] is lineNumberId + delete @screenRowsByLineNumberId[lineNumberId] + node.removeChild(lineNumberNode) + + buildLineNumberHTML: (bufferRow, softWrapped, maxLineNumberDigits, screenRow) -> + if screenRow? + {lineHeight} = @props + style = "position: absolute; top: #{screenRow * lineHeight}px;" else - lineNumber + @iconDivHTML + style = "visibility: hidden;" + innerHTML = @buildLineNumberInnerHTML(bufferRow, softWrapped, maxLineNumberDigits) - iconDivHTML: '
' + "